Initial public push: docs cosmos v4 + AI module + framework groundwork

This is the snapshot the production landing site (nibiru-framework.com) is
deployed from. Brings together the recent splash + docs migration to the v4
"Cosmos" design system, the new in-framework AI module, and the framework
groundwork that backs the framework-reference extraction.

What lands:
- docs/: Astro + Starlight site with the v4 dark cosmic palette, GalaxyHero
  canvas constellation, Mission Control chat (wired to /api/oracle →
  api.neuronetz.ai via providers.mjs Ollama), 5-panel MMVC stage
  (Model · AI · Module · Controller · View), translated EN/DE/JA/ES/FR
  content, PWA + sitemap + llms.txt + Umami analytics.
- docs/design-system/: canonical mockup bundle (source/index-v2.html for
  splash, source/docs-system.html + preview/ for docs, SPEC.md, tokens).
- docs/scripts/extraction/framework-reference-v2.md: deep framework
  reference (~1.6k lines, file:line citations, every public factory and
  idiom — basis for the LoRA training corpus.
- application/module/ai/: AI module with chat / embed / RAG / agent
  plugins, plus pdoQuery / httpGet / fileRead tools and Modelfile +
  smoke-test in training/.
- application/module/users/: user / ACL / form-factory traits used as the
  reference plugin pattern for the framework docs.
- application/settings/config/database/: schema + seed migrations
  including the AI module tables (200–203).
- Form factory + autogenerator changes the framework-reference-v2 covers.

Production secrets stay out: docs/.env, settings.production.ini and
ai.production.ini are all gitignored (.example files are in tree).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
stephan
2026-05-08 15:22:18 +02:00
parent a60ce90643
commit 48c839d927
662 changed files with 172811 additions and 1 deletions

12
docs/.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
dist
.git
.github
.vscode
.idea
*.log
.env
.env.*
!.env.example
.DS_Store
.astro

40
docs/.env.example Normal file
View File

@@ -0,0 +1,40 @@
# Copy to .env on the host and fill in the values you actually need.
# .env is gitignored.
#
# DEFAULT MODE: Ollama on api.neuronetz.ai
# The Oracle uses your own GPU-backed Ollama by default — no paid API keys
# required. Just make sure the chat + embedding models are pulled (see
# below) and you're done.
# === Default Ollama backend (used when LLM_PROVIDER and EMBED_PROVIDER are
# 'ollama' — which they are by default) ===
# LLM_PROVIDER=ollama
OLLAMA_BASE_URL=https://api.neuronetz.ai
OLLAMA_CHAT_MODEL=qwen2.5-coder:14b
OLLAMA_EMBED_MODEL=nomic-embed-text
# Pull these once on your Ollama host:
# curl https://api.neuronetz.ai/api/pull -d '{"name":"qwen2.5-coder:14b"}'
# curl https://api.neuronetz.ai/api/pull -d '{"name":"nomic-embed-text"}'
# === Oracle behaviour ===
ORACLE_TOP_K=6
ORACLE_MAX_TOKENS=800
# === Let's Encrypt / nginx-proxy ===
LETSENCRYPT_EMAIL=stephan.kasdorf@bittomine.com
# ===========================================================================
# OPTIONAL FALLBACKS — only set these if you want to switch off Ollama.
# ===========================================================================
# To use Claude as the answering model:
# LLM_PROVIDER=anthropic
# ANTHROPIC_API_KEY=sk-ant-...
# ANTHROPIC_MODEL=claude-haiku-4-5-20251001
# To use OpenAI for embeddings (build time + runtime):
# EMBED_PROVIDER=openai
# OPENAI_API_KEY=sk-...
# OPENAI_EMBED_MODEL=text-embedding-3-small

21
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

54
docs/Dockerfile Normal file
View File

@@ -0,0 +1,54 @@
# syntax=docker/dockerfile:1.6
# ============================================================================
# Nibiru docs site — multi-stage build
#
# Default backend: your Ollama at https://api.neuronetz.ai (no API keys).
# See docker-compose.yml + .env.example for overrides.
# ============================================================================
FROM node:22-alpine AS builder
WORKDIR /app
ENV NODE_ENV=production
ENV NPM_CONFIG_PRODUCTION=false
COPY package.json package-lock.json .npmrc ./
RUN npm ci
COPY . .
# Build-time embedding generation. Soft-fails so missing models / network
# don't break the build — runtime falls back to chat-only mode.
ARG OLLAMA_BASE_URL=https://api.neuronetz.ai
ARG OLLAMA_EMBED_MODEL=nomic-embed-text
ARG EMBED_PROVIDER=ollama
ARG OPENAI_API_KEY=""
ENV OLLAMA_BASE_URL=$OLLAMA_BASE_URL
ENV OLLAMA_EMBED_MODEL=$OLLAMA_EMBED_MODEL
ENV EMBED_PROVIDER=$EMBED_PROVIDER
ENV OPENAI_API_KEY=$OPENAI_API_KEY
RUN node scripts/build-oracle-index.mjs || true
# Build the static site + SSR entry.
RUN npm run build:site
# Build the LoRA training corpus as a side artifact (deterministic, no API).
RUN node scripts/build-corpus.mjs || true
RUN npm prune --omit=dev
# ----------- Stage 2: runtime -----------------------------------------------
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/public ./public
COPY --from=builder /app/scripts ./scripts
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 4321
USER node
CMD ["node", "./dist/server/entry.mjs"]

49
docs/README.md Normal file
View File

@@ -0,0 +1,49 @@
# Starlight Starter Kit: Basics
[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build)
```
npm create astro@latest -- --template starlight
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro + Starlight project, you'll see the following folders and files:
```
.
├── public/
├── src/
│ ├── assets/
│ ├── content/
│ │ └── docs/
│ └── content.config.ts
├── astro.config.mjs
├── package.json
└── tsconfig.json
```
Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name.
Images can be added to `src/assets/` and embedded in Markdown with a relative link.
Static assets, like favicons, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Check out [Starlights docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).

390
docs/astro.config.mjs Normal file
View File

@@ -0,0 +1,390 @@
// @ts-check
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import sitemap from '@astrojs/sitemap';
import node from '@astrojs/node';
import AstroPWA from '@vite-pwa/astro';
export default defineConfig({
site: 'https://nibiru-framework.com',
output: 'server',
adapter: node({ mode: 'standalone' }),
// Disable Astro's dev-mode floating toolbar — it's the small dark bar with
// component-inspect controls. Useful for Astro debugging, not needed for
// reviewing the design.
devToolbar: { enabled: false },
redirects: {
'/': '/en/',
},
integrations: [
AstroPWA({
registerType: 'autoUpdate',
includeAssets: [
'favicon.svg',
'img/nibiru-logo.png',
'img/lifecycle.svg',
],
manifest: {
name: 'Nibiru — Modular MMVC PHP Framework',
short_name: 'Nibiru',
description:
'A modular MMVC PHP framework for builders who ship. First-class AI, the Atelier × Cosmos design system, on-brand docs.',
start_url: '/en/',
scope: '/',
display: 'standalone',
orientation: 'portrait-primary',
background_color: '#fdf6df',
theme_color: '#7c7bb8',
lang: 'en',
categories: ['developer-tools', 'productivity', 'education'],
icons: [
{ src: '/img/pwa-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/img/pwa-512.png', sizes: '512x512', type: 'image/png' },
{
src: '/img/pwa-512-maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
shortcuts: [
{
name: 'Quick Start',
short_name: 'Start',
url: '/en/start/quick-start/',
description: 'Build your first Nibiru page in five minutes.',
},
{
name: 'AI module',
short_name: 'AI',
url: '/en/ai/module/overview/',
description: 'First-class AI in your Nibiru app.',
},
{
name: 'Why Nibiru',
short_name: 'Why',
url: '/en/why-nibiru/',
description: 'Differentiators with code references.',
},
],
},
workbox: {
navigateFallback: '/en/',
// SSR /api/* endpoints — never cache, always go to network.
navigateFallbackDenylist: [/^\/api\//],
// Don't precache the heavy hero illustrations — they're served
// from network and runtime-cached after first view.
globPatterns: [
'**/*.{html,css,js,svg,woff2,json,xml}',
'img/pwa-*.png',
'img/apple-touch-icon.png',
'img/nibiru-*.png',
'img/lotus-divider.png',
'img/showcase-*.png',
'img/hero-backdrop.png',
],
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024,
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'StaleWhileRevalidate',
options: { cacheName: 'google-fonts-stylesheets' },
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-webfonts',
expiration: { maxEntries: 16, maxAgeSeconds: 60 * 60 * 24 * 365 },
},
},
{
urlPattern: /\/img\/.*\.(png|svg|jpg|jpeg|webp)$/i,
handler: 'CacheFirst',
options: {
cacheName: 'nibiru-images',
expiration: { maxEntries: 60, maxAgeSeconds: 60 * 60 * 24 * 30 },
},
},
],
},
devOptions: {
enabled: false, // PWA runs only in production builds
navigateFallback: '/en/',
},
}),
sitemap({
i18n: {
defaultLocale: 'en',
locales: { en: 'en', de: 'de', ja: 'ja', es: 'es', fr: 'fr' },
},
filter: (page) => !page.includes('/api/'),
}),
starlight({
title: 'Nibiru',
description:
'A modular MMVC PHP framework for rapid prototyping. Born among the old gods, built for modern builders.',
// Umami analytics — self-hosted, cookie-free, honours Do-Not-Track.
// No GDPR consent banner needed because no PII or cross-site state
// is captured. The script is only injected on production builds.
head: import.meta.env.PROD
? [
{
tag: 'script',
attrs: {
defer: true,
src: 'https://analytics.neuronetz.ai/script.js',
'data-website-id': '5f2143d2-775b-4269-afcd-2639381add47',
'data-do-not-track': 'true',
'data-exclude-search': 'true',
},
},
]
: [],
logo: {
src: './public/img/nibiru-logo.png',
replacesTitle: true,
alt: 'Nibiru — Create, Invent, Impress',
},
favicon: '/favicon.svg',
editLink: { baseUrl: 'https://github.com/alllinux/Nibiru/edit/master/docs/' },
lastUpdated: true,
defaultLocale: 'en',
locales: {
en: { label: 'English', lang: 'en' },
de: { label: 'Deutsch', lang: 'de' },
ja: { label: '日本語', lang: 'ja' },
es: { label: 'Español', lang: 'es' },
fr: { label: 'Français', lang: 'fr' },
},
customCss: [
// 1. Brand tokens (--nibiru-*). Mirrored from /design-system/ by
// scripts/sync-design-system.mjs on every dev/build.
'./src/styles/design-system/tokens.css',
// 2. Docs-system tokens — verbatim from the design handoff. Defines
// --space, --space-2/3, --plum, --star-soft, --nebula-cyan/rose/green,
// --note-fg, --r-md, --fs-base, etc. Must come BEFORE components.css
// and nibiru.css since both consume these vars.
'./src/styles/docs-system/tokens.css',
// 3. Docs-system components — verbatim from the handoff. Provides the
// .topnav / .sidebar / .toc / .prose / .code-block / .callout-* /
// .doc-table / .tab-* / .api-* / .card / .search-modal / .fab styles.
'./src/styles/docs-system/components.css',
// 4. Fonts (Starlight needs --sl-font set before its own theme runs)
'./src/styles/fonts.css',
// 5. Site-specific styles + Starlight overrides (splash sections,
// legacy aliases, oracle launcher, atelier-button shims).
'./src/styles/nibiru.css',
// 6. Starlight × handoff bridge — translates Starlight's actual class
// names (.top-level, a[aria-current='page'], starlight-toc,
// .sl-markdown-content, .pagination-links, .starlight-aside-*)
// into the handoff's visual rules. Loaded LAST so it can win the
// cascade over both Starlight's defaults and earlier styles.
'./src/styles/starlight-docs-bridge.css',
],
components: {
Hero: './src/components/GalaxyHero.astro',
Header: './src/components/BrandHeader.astro',
// Article header — emits .breadcrumbs / <h1><em> / .doc-lede / .doc-meta
PageTitle: './src/components/PageTitle.astro',
// Article footer — Pagination + .help-strip ("Was this page helpful?")
Footer: './src/components/Footer.astro',
// Lock to dark — Cosmos is dark-only by design.
ThemeSelect: './src/components/ThemeSelectStub.astro',
},
expressiveCode: {
themes: ['github-light', 'github-dark'],
shiki: {
langAlias: {
smarty: 'html',
tpl: 'html',
},
},
styleOverrides: {
borderRadius: '0.75rem',
frames: {
shadowColor: 'rgba(124, 123, 184, 0.28)',
},
},
},
social: [
{
icon: 'github',
label: 'GitHub',
href: 'https://github.com/alllinux/Nibiru',
},
],
sidebar: [
{
label: 'Get started',
translations: {
de: 'Erste Schritte',
ja: 'はじめに',
es: 'Primeros pasos',
fr: 'Premiers pas',
},
items: [
{
label: 'What is Nibiru?',
slug: 'start/what-is-nibiru',
translations: {
de: 'Was ist Nibiru?',
ja: 'Nibiru とは?',
es: '¿Qué es Nibiru?',
fr: 'Qu\'est-ce que Nibiru ?',
},
},
{
label: 'Why Nibiru, not Laravel',
slug: 'why-nibiru',
translations: {
de: 'Warum Nibiru, nicht Laravel',
ja: 'なぜ Nibiru なのか',
es: '¿Por qué Nibiru y no Laravel?',
fr: 'Pourquoi Nibiru, pas Laravel',
},
},
{
label: 'Installation',
slug: 'start/installation',
translations: {
de: 'Installation',
ja: 'インストール',
es: 'Instalación',
fr: 'Installation',
},
},
{
label: 'Quick Start',
slug: 'start/quick-start',
translations: {
de: 'Schnellstart',
ja: 'クイックスタート',
es: 'Inicio rápido',
fr: 'Démarrage rapide',
},
},
{
label: 'Project Structure',
slug: 'start/structure',
translations: {
de: 'Projektstruktur',
ja: 'プロジェクト構成',
es: 'Estructura del proyecto',
fr: 'Structure du projet',
},
},
{
label: 'Run It Locally',
slug: 'start/local-testing',
translations: {
de: 'Lokal ausführen',
ja: 'ローカル実行',
es: 'Ejecución local',
fr: 'Lancer en local',
},
},
{
label: 'Deployment',
slug: 'start/deployment',
translations: {
de: 'Deployment',
ja: 'デプロイメント',
es: 'Despliegue',
fr: 'Déploiement',
},
},
],
},
{
label: 'The framework',
translations: {
de: 'Das Framework',
ja: 'フレームワーク',
es: 'El framework',
fr: 'Le framework',
},
items: [
{ label: 'Architecture (MMVC)', slug: 'core/architecture' },
{ label: 'Bootstrap & Dispatcher', slug: 'core/dispatcher' },
{ label: 'Routing', slug: 'core/routing' },
{ label: 'Controllers', slug: 'core/controllers' },
{ label: 'Views & Smarty', slug: 'core/views' },
{ label: 'Models', slug: 'core/models' },
{ label: 'Modules', slug: 'core/modules' },
{ label: 'Forms', slug: 'core/forms' },
{ label: 'Database & Migrations', slug: 'core/database' },
{ label: 'Auth', slug: 'core/auth' },
{ label: 'Config & Settings', slug: 'core/config' },
{ label: 'Pagination', slug: 'core/pagination' },
{ label: 'Registry', slug: 'core/registry' },
],
},
{
label: 'CLI',
translations: {
de: 'CLI',
ja: 'CLI',
es: 'CLI',
fr: 'CLI',
},
items: [
{ label: 'Overview', slug: 'cli/overview' },
{ label: 'Modules & Controllers', slug: 'cli/scaffolding' },
{ label: 'Migrations', slug: 'cli/migrations' },
{ label: 'CMS Pages', slug: 'cli/cms' },
],
},
{
label: 'Design System',
translations: {
de: 'Design-System',
ja: 'デザインシステム',
es: 'Sistema de diseño',
fr: 'Système de design',
},
items: [
{ label: 'Overview', slug: 'design/overview' },
{ label: 'Palette', slug: 'design/palette' },
{ label: 'Typography', slug: 'design/typography' },
{ label: 'Components', slug: 'design/components' },
{ label: 'Motion', slug: 'design/motion' },
],
},
{
label: 'In production',
translations: {
de: 'Im Einsatz',
ja: '実例',
es: 'En producción',
fr: 'En production',
},
items: [
{ label: 'Real-World Projects', slug: 'showcase/projects' },
{ label: 'Patterns from Production', slug: 'showcase/patterns' },
],
},
{
label: 'AI in Nibiru',
translations: {
de: 'KI in Nibiru',
ja: 'Nibiru の AI',
es: 'IA en Nibiru',
fr: "L'IA dans Nibiru",
},
items: [
{ label: 'The AI module', slug: 'ai/module/overview' },
{ label: 'Chat plugin', slug: 'ai/module/chat' },
{ label: 'Embed plugin', slug: 'ai/module/embed' },
{ label: 'RAG plugin', slug: 'ai/module/rag' },
{ label: 'Agent plugin', slug: 'ai/module/agent' },
{ label: 'Training nibiru-coder', slug: 'ai/module/training' },
{ label: 'Ask the Oracle', slug: 'ai/oracle' },
{ label: 'Training corpus (LoRA)', slug: 'ai/corpus' },
{ label: 'Roadmap', slug: 'ai/roadmap' },
],
},
],
}),
],
});

View File

@@ -0,0 +1,61 @@
# Nibiru Atelier — Design System
The visual language of the Nibiru framework. A lotus on cream paper, lit by morning light.
## What's here
| File | Purpose |
|---|---|
| `tokens.json` | Source of truth, [W3C Design Tokens Community Group](https://design-tokens.github.io/community-group/format/) format. |
| `tokens.css` | CSS custom properties, namespaced `--nibiru-*`. |
| `tokens.scss` | SCSS variables and maps. |
| `tailwind.preset.js` | Tailwind preset extending colours, fonts, shadows. |
## Use
### CSS
```html
<link rel="stylesheet"
href="https://nibiru-framework.com/design-system/tokens.css">
<style>
body { background: var(--nibiru-paper); color: var(--nibiru-ink);
font-family: var(--nibiru-font-text); }
h1 { font-variation-settings: var(--nibiru-fv-display-hero);
letter-spacing: var(--nibiru-tracking-display); }
.cta { background: var(--nibiru-ink); color: var(--nibiru-paper);
padding: 0.7rem 1.2rem;
border-radius: var(--nibiru-radius-md); }
</style>
```
### Tailwind
```jsx
<button className="bg-nibiru-ink text-nibiru-paper px-5 py-2 rounded-md font-display tracking-body">
Read the docs
</button>
<h1 className="text-hero text-nibiru-ink tracking-display">
Create. Invent. <em className="text-nibiru-iris-deep">Impress.</em>
</h1>
```
## Full guide
→ <https://nibiru-framework.com/en/design/overview/>
## Versioning
`4.0.0`**Cosmos**. Dark-first AI-framework brand. Magenta-amber-blue nebula
palette on a deep-space body, Space Grotesk display + Inter Tight body +
JetBrains Mono. Light "paper" surfaces still available via `.alt` sections for
content-heavy reading.
`3.0.0`**Atelier × Cosmos**. Reconciles the editorial atelier (paper-and-ink)
with a modern AI-tool dialect: warmer butter page (`#fdf6df`), Geist type,
rounder surfaces (816 px), plus a dark cosmic sub-palette and a code surface
stack for hero / chat / code-card components.
`2.0.0` — Atelier release. Lotus-violet, sky-blue and warm-cream system drawn
from the actual brand mark. Bricolage Grotesque, editorial radii.

406
docs/design-system/SPEC.md Normal file
View File

@@ -0,0 +1,406 @@
# Handoff: Nibiru Docs Design System
## Overview
This handoff covers the **Nibiru documentation site** redesign — a cosmic-themed, dark-primary design system for the framework's docs portal. It includes a complete component library (navigation, code blocks, callouts, tables, API references, search modal, 404, etc.) plus a working full-page preview of the "What is Nibiru?" article styled with the new system.
The metaphor carried throughout: **Nibiru is a star map**. The framework's MMVC architecture (Model · Module · View · Controller) is a constellation, and the docs adopt the same atmospheric vocabulary — deep cosmic plum backgrounds, magenta/cyan/amber accents echoing nebula colors, terminal-aesthetic code blocks, and subtle galaxy textures behind content.
A companion start page (`index-v2.html`) is included for context — it's the marketing site this docs design extends.
## About the Design Files
The files in this bundle are **design references created in HTML** — interactive prototypes showing intended look, behavior, and layout. They are **not production code to copy directly**.
Your task is to **recreate these designs in the Nibiru docs site's actual environment** (likely a static-site generator like Nextra, Mintlify, Docusaurus, or VitePress, given the original screenshot's nav structure), using whatever templating, theming, and component patterns that environment provides. The HTML/JSX in this bundle uses React + Babel inline as a fast prototyping medium — your real implementation should use your stack's idioms.
If no docs framework has been chosen yet, **Nextra (with the docs theme) or Mintlify** are good fits — both support exactly this kind of three-column layout (sidebar + content + on-this-page TOC) with custom theming hooks.
## Fidelity
**High-fidelity.** All colors, typography, spacing, border radii, shadows, and component states are final. Treat the hex values, font sizes, and CSS tokens in `tokens.css` as the source of truth.
The single open question is how dark/light theme switching is wired — the prototype uses a `.theme-light` class on a wrapper element, but your stack may prefer `data-theme` attributes or CSS `color-scheme`.
## Screens / Views
The design canvas in `source/docs-system.html` shows every component grouped into reviewable sections. The full-page preview is the most important reference — it shows how everything composes.
### 1. Full Page Preview ("What is Nibiru?")
**Purpose**: Recreate the documentation article from the original screenshot in the new design system.
**Layout**: Three-column grid, full viewport height.
- **Top nav**: 60px tall, sticky, `backdrop-filter: blur(16px)`, semi-transparent background.
- **Main grid**: `grid-template-columns: 280px 1fr 240px` (sidebar / content / TOC).
- **Content column**: `max-width: 760px`, centered within its track, padding `32px 48px`.
**Components used**: TopNav · Sidebar · PageHeader · Prose body · DocTable · Callout · Pagination · RightTOC
### 2. Top Nav
**Purpose**: Brand mark, search, GitHub link, theme toggle, locale picker.
**Layout**: `grid-template-columns: 240px 1fr auto`, gap 32px, padding `0 24px`, height 60px.
**Components**:
- **Brand**: Lotus PNG (36px tall) + "Nibiru" (17px / 600) + "Create, Invent, Impress" tagline (10px muted, lowercase).
- **Search button**: Pill-shaped, 420px max-width, centered. `background: var(--space-2)`, `border: 1px solid var(--line)`, contains search icon + "Search" placeholder + `⌘ K` kbd badge. Hover: brighten background.
- **Right cluster**: GitHub icon (36×36), theme toggle (sun/moon), locale dropdown ("EN ▾"). All 36×36 with hover background `var(--space-2)`.
### 3. Sidebar
**Purpose**: Sectioned navigation with collapsible groups and active-page indication.
**Layout**: 280px wide, padding `24px 12px 24px 24px`, `border-right: 1px solid var(--line)`, faint background tint `rgba(11,4,16,0.4)`.
**Sections**: "Get Started" / "The Framework" / "CLI" / "Advanced" — each a collapsible group with a chevron that rotates 0deg/-90deg.
**Section heading**: 11px JetBrains Mono, 0.12em letter-spacing, uppercase, muted color.
**Items**: 13.5px, padding `6px 10px`, border-radius 8px. Hover: background `var(--space-2)`. Active state has a magenta gradient background (`linear-gradient(90deg, rgba(184,107,255,0.18), rgba(184,107,255,0.04))`) and a 3px magenta indicator bar with a glow shadow on the left edge.
**Badges**: `core` (magenta tint) and `new` (green tint), 9px JetBrains Mono uppercase.
### 4. Right TOC ("On this page")
**Layout**: 240px wide, padding `24px 16px 24px 24px`.
**Heading**: 11px JetBrains Mono uppercase muted.
**List**: `border-left: 1px solid var(--line)`, items 13px with 12px padding-left. Active item: magenta text + magenta 2px left border (replacing the 1px line border at that position).
**Sub-items (level 2)**: Indented 24px, 12.5px, more muted.
**Footer**: "Edit this page" link with pencil icon.
### 5. Page Header
**Layout**: `margin-bottom: 32px`, `padding-bottom: 24px`, `border-bottom: 1px solid var(--line)`.
**Breadcrumbs**: 11px JetBrains Mono uppercase, separated by `/`. Last crumb is brighter.
**Title**: 36px / 600 / -0.025em letter-spacing.
**Summary**: 18px, color `var(--star-soft)`, max-width 60ch.
**Last-updated meta**: 11px JetBrains Mono with a 6px green dot (with green glow shadow).
### 6. Body Prose
**Base**: 16px / 1.7 line-height, color `var(--star-soft)`.
**h2**: 28px / 600, with a 24px magenta gradient line above it (`width: 24px; height: 1px; background: linear-gradient(90deg, var(--nebula-mag), transparent)`). 40px top margin, 16px bottom margin.
**h3**: 22px / 600. 32px top, 12px bottom.
**em**: Restyled — color `var(--nebula-mag-2)`, NOT italic.
**Inline code**: Monospace 0.86em, magenta-2 text on `var(--space-2)` with 1px line border, 4px radius, 1px 6px padding.
**Blockquote**: 2px magenta left border, 16px left padding, magenta gradient fade background.
**ul markers**: Magenta accent.
### 7. Code Block
**Aesthetic**: Mission-Control terminal — `background: #0a0210`, 12px radius, magenta gradient overlay (4% opacity, 135deg from top-left).
**Header**: `padding: 8px 14px`, `border-bottom: 1px solid rgba(244,238,219,0.06)`, `background: rgba(0,0,0,0.3)`.
- Three colored dots (rose, amber, green at 50% alpha — like macOS traffic lights but recolored to nebula palette).
- Filename in JetBrains Mono 11px.
- Language badge (10px JetBrains Mono uppercase, magenta text on transparent background with magenta border).
- Copy button on the right (changes to "Copied ✓" for 1.4s on click).
**Body**: 13px JetBrains Mono, 1.7 line-height, padding `16px 20px`.
**Syntax tokens** (CSS classes already defined):
| Token | Class | Color |
|---|---|---|
| Keyword | `.tk-kw` | `#ff7ab8` rose |
| Class/type | `.tk-cn` | `#ffb574` amber |
| Function | `.tk-fn` | `#6ad9ff` cyan |
| Variable | `.tk-var` | `#d8a8ff` magenta |
| String | `.tk-str` | `#6ee7b0` green |
| Number | `.tk-num` | `#ffb574` amber |
| Namespace | `.tk-ns` | muted cream |
| Comment | `.tk-cm` | very muted, italic |
In production, hook these classes up to your syntax highlighter (Shiki, Prism, etc.) via a custom token theme.
### 8. Callouts (Note / Tip / Warning / Danger)
**Layout**: Flex row with 14px gap, 14px×18px padding, 8px radius, 1px colored border, tinted background (~6% alpha of the accent), radial gradient overlay at 5% opacity from top-left.
**Glyph**: 24×24 rounded with `filter: drop-shadow(0 0 6px currentColor)` for the cosmic glow.
**Color mapping**:
| Kind | Color | Hex |
|---|---|---|
| Note | Cyan | `#6ad9ff` |
| Tip | Green | `#6ee7b0` |
| Warning | Amber | `#ffb574` |
| Danger | Rose | `#ff7ab8` |
**Title**: 13px / 600 in the accent color.
**Body**: 15px in `var(--star-soft)`.
### 9. Tables
**Layout**: `border: 1px solid var(--line)`, 8px radius, `overflow: hidden`.
**Header**: `background: var(--space-2)`, padding `10px 16px`, 11px JetBrains Mono uppercase muted.
**Rows**: `padding: 12px 16px`, 13.5px, dashed bottom border. Hover: `background: rgba(184,107,255,0.04)`.
**First column**: Brighter (`var(--star)`), 500 weight, `white-space: nowrap`.
**Inline `<code>`**: Magenta-2 on `var(--space-2)`.
### 10. Tabs
**List**: `background: var(--space-2)`, padding 4px, 4px gap.
**Trigger**: 6px×14px padding, 13px / 500. Inactive: muted text. Active: bright text on `var(--space-3)`, 1px magenta inset shadow at the top, sm shadow.
**Use case**: "PHP / SQL / YAML" code variants, "macOS / Linux / Windows" install instructions.
### 11. API Block
**Container**: `padding: 18px 20px`, 12px radius, gradient background (`linear-gradient(180deg, var(--space-2), transparent)`).
**Signature**: 14px JetBrains Mono on `rgba(0,0,0,0.3)` with line border, 8px radius. Highlights function name (cyan), arg names (magenta-2), types (amber). Punctuation muted.
**Section labels**: "Parameters" / "Returns" — 10px JetBrains Mono uppercase muted.
**Param row**:
- Name (13px JetBrains Mono magenta-2 / 500)
- Type pill (11px JetBrains Mono amber on amber-tinted bg)
- Optional `required` pill (rose tint)
- Optional `default: <code>...</code>` (muted, with brighter code value)
- Description (13.5px star-soft)
- Dashed bottom border between params
### 12. Cards / Feature Grid
**Layout**: `grid-template-columns: repeat(2, 1fr)`, 14px gap.
**Card**: `background: var(--space-2)`, 1px line border, 12px radius, 18px padding. Hover: border becomes magenta-tinted, lifts 2px, background brightens, top-right arrow icon fades in & slides into place.
**Icon block**: 36×36 rounded square with a custom gradient background per card (mag→rose, cyan→mag, amber→rose, green→cyan).
**Title**: 15px / 600. **Description**: 13px star-soft.
### 13. Search Modal (⌘K)
**Container**: 580px wide, 16px radius, `background: var(--space-2)`, glow shadow (`0 0 60px rgba(184,107,255,0.1)`).
**Sections**:
- Input row (16px placeholder text, search icon, `esc` kbd badge).
- "Recent" — pills (rounded, `var(--space-3)` background).
- "Results" — list with kind badges (page=magenta, section=cyan, api=amber), title with breadcrumb prefix, excerpt, `→` indicator. Active result has a magenta left border and tinted bg.
- Footer — kbd hints (`↑↓` navigate · `↵` open · `⌘K` close) + "Powered by Nibiru Search".
**Highlight matches**: `<mark>` element gets `background: rgba(184,107,255,0.3)`, no padding.
### 14. Pagination
**Layout**: Two-column grid, top border, 48px top margin.
**Link card**: `background: var(--space-2)`, 1px border, 12px radius, padding `14px 18px`. Two-line content: small "Previous"/"Next" label (10px JetBrains Mono uppercase muted) + 14px / 500 page title.
**Hover**: Border picks up magenta glow, bg brightens, chevron arrow translates ±3px.
### 15. Floating Action Button (Help)
**Button**: 52px circle, gradient `linear-gradient(135deg, magenta, rose)`, 12px×32px shadow + inner white highlight.
**Pulse ring**: `::after` pseudo-element animating from scale(1) → scale(1.4), opacity 0.7 → 0, infinite 2.4s.
**Tooltip**: Pops out to the left on hover (kept in DOM for review).
### 16. 404 / "Off-orbit"
**Layout**: Centered, 520px max-width.
**Hero illustration**: SVG with two dashed orbital ellipses around a bright central star (radial gradient white→amber→magenta-fade), and a cyan probe drifting off-orbit at the top-right with a "PROBE-404" callout label and a dashed line connecting it back to the star.
**Code label**: "404 / off-orbit" in 11px JetBrains Mono cyan.
**Title**: "This page drifted away from Nibiru." 28px / 600.
**Actions**: Primary button (magenta solid) + ghost button (line border).
### 17. Mobile Drawer
**Container**: 320px wide, 640px tall in the prototype (in real life: full viewport with slide-in animation).
**Structure**: Header (brand + close X) → search button → sidebar (full nav) → footer (Theme / EN / GitHub pills).
## Interactions & Behavior
| Interaction | Trigger | Behavior |
|---|---|---|
| Theme toggle | Click sun/moon icon in nav | Add/remove `.theme-light` on root. Persist to `localStorage`. |
| Sidebar collapse | Click section heading | Toggle children visibility, rotate chevron 90°. Animate `transform: rotate()` 200ms ease. |
| Sidebar active | Current page | Apply `.active` class — magenta gradient bg + 3px magenta glowing left bar. |
| TOC active | Scroll position | Use `IntersectionObserver` on `.prose h2[id]` elements. Highlight matching TOC item. |
| Search open | `⌘K` / `Ctrl+K` / click search button | Open modal, focus input. `Esc` closes. ↑↓ navigates results, ↵ opens. |
| Code copy | Click "Copy" button | Copy to clipboard, swap to "Copied ✓" for 1400ms, then revert. |
| Tab switch | Click trigger | Update `aria-selected`, swap panel. No transition — instant. |
| Card hover | Pointer enter | `transform: translateY(-2px)`, border magenta-tint, arrow fades in & translates. 200ms transitions. |
| Pagination hover | Pointer enter | Chevron translates ±3px in the direction of travel, border magenta. |
| FAB pulse | Always | 2.4s infinite ring animation. |
## State Management
This is a docs site — most state is URL-driven (current page, anchor, search query). The minimum client state needed:
- `theme: "dark" | "light"` (persisted to `localStorage`)
- `sidebarOpen: boolean` (mobile drawer)
- `searchOpen: boolean` (⌘K modal)
- `expandedSections: Record<string, boolean>` (sidebar collapse — defaults to "current section open")
- `activeTocId: string` (driven by IntersectionObserver, not user input)
- `copyStateByBlock: Record<string, boolean>` (per-block "Copied" flash — local component state is fine)
## Design Tokens
All tokens live in **`source/docs-system/tokens.css`**. Copy this file into your codebase verbatim — it's the source of truth.
### Colors (dark theme)
| Name | Hex | Use |
|---|---|---|
| `--space` | `#0b0410` | Page background |
| `--space-2` | `#120822` | Panel surfaces |
| `--space-3` | `#1a0c2e` | Raised surfaces |
| `--plum` | `#2a1545` | Deep accents |
| `--plum-2` | `#3a1d5e` | Hover plum |
| `--star` | `#f4eedb` | Primary text |
| `--star-soft` | `#d8d2c0` | Secondary text |
| `--muted` | `#8b85a3` | Tertiary / labels |
| `--muted-2` | `#5e5878` | Very muted |
| `--nebula-mag` | `#b86bff` | Primary accent · links |
| `--nebula-mag-2` | `#d8a8ff` | Hover / em |
| `--nebula-cyan` | `#6ad9ff` | Note callout |
| `--nebula-amber` | `#ffb574` | Warning callout / numbers |
| `--nebula-rose` | `#ff7ab8` | Danger callout / keywords |
| `--nebula-green` | `#6ee7b0` | Tip callout / strings |
### Light theme overrides
Defined under `.theme-light` in `tokens.css` — accent colors shift to higher-contrast equivalents (purple replaces magenta, teal replaces cyan) against a parchment `#faf6ec` background.
### Type scale
| Token | px | Use |
|---|---|---|
| `--fs-xs` | 12 | Tiny meta |
| `--fs-sm` | 13 | UI |
| `--fs-base` | 15 | Default |
| `--fs-md` | 16 | Body prose |
| `--fs-lg` | 18 | Page summary |
| `--fs-xl` | 22 | h3 |
| `--fs-2xl` | 28 | h2 |
| `--fs-3xl` | 36 | Page title |
| `--fs-4xl` | 48 | Page hero |
### Spacing
4 / 8 / 12 / 16 / 20 / 24 / 32 / 40 / 48 / 64 — exposed as `--space-1` through `--space-16` (note: var name pun with the cosmic palette `--space`; see comment in tokens.css).
### Radii
4 / 8 / 12 / 16 / 20 / 999px (pill).
### Shadows
- `--shadow-sm`: `0 1px 2px rgba(0,0,0,0.3)`
- `--shadow-md`: `0 8px 24px -8px rgba(0,0,0,0.5)`
- `--shadow-lg`: `0 24px 48px -16px rgba(0,0,0,0.6)`
- `--shadow-glow`: `0 0 24px rgba(184,107,255,0.25)`
### Fonts
- **Sans**: `Inter Tight` (400/500/600/700) — fall back to Inter, system sans
- **Mono**: `JetBrains Mono` (400/500/600)
- No display serif — explicitly all-sans for density and clarity.
Load via Google Fonts:
```html
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
```
Or self-host for performance.
## Assets
- **`source/docs-system/assets/lotus.png`** — The Nibiru lotus mark, cropped from the supplied logo. 861×569. Use at 36px height in the nav (auto width). For a smaller mark icon variant, ask the brand owner for a square crop.
- **`source/docs-system/assets/nibiru-full.png`** — The full logo with "Nibiru" wordmark and "Create, Invent, Impress" tagline. Use on a marketing splash or auth page; not the docs nav.
The docs nav uses **lotus + typeset wordmark separately** so we get crisper text than scaling a PNG, and so the tagline can use our `--muted` token color.
## Files
```
source/
├── docs-system.html # Main design canvas — every component side-by-side
├── design-canvas.jsx # The canvas component itself (pan/zoom shell, you can ignore this)
├── index-v2.html # Companion: the marketing start page (for brand context)
└── docs-system/
├── tokens.css # ★ DESIGN TOKENS — copy into your codebase
├── components.css # ★ COMPONENT STYLES — copy or port to your styling system
├── assets/
│ ├── lotus.png # Brand mark
│ └── nibiru-full.png # Full logo
└── components/
├── navigation.jsx # TopNav, Sidebar, RightTOC, LotusMark
├── typography-and-code.jsx # PageHeader, Prose, CodeBlock, Callout, DocTable
└── extras.jsx # Tabs, ApiBlock, CardGrid, SearchModal, FAB, Pagination,
# MobileDrawer, NotFound
preview/
└── nibiru-docs-system.html # Single-file bundled preview (all assets inlined,
# works offline, just open in a browser)
```
### Where to start
1. Open `preview/nibiru-docs-system.html` in a browser to see the full design canvas. **This file works offline** — everything is inlined, just double-click it.
2. Read `source/docs-system/tokens.css` end-to-end — these tokens drive everything.
3. Read `source/docs-system/components.css` — every visual decision is here, organized by component with `/* ====== COMPONENT ====== */` headers.
4. Check the JSX components for structure (don't port React verbatim — just use them as a reference for DOM hierarchy and class names).
5. Pick a target stack (Nextra / Mintlify / VitePress / your own React app) and start porting. The CSS will land cleanly because it's plain CSS variables + class selectors — no framework lock-in.
### ⚠️ Running the source HTML locally
The files in `source/` use external `<script src="...jsx">` imports. **They will NOT work if you double-click `docs-system.html` directly** — browsers block cross-origin requests on `file://` URLs, so the JSX modules fail to load and you'll see `ReferenceError: TopNav is not defined` in the console.
You need a local HTTP server. From the project root:
```bash
# Python (pre-installed on macOS / Linux)
cd design_handoff_nibiru_docs_system/source
python3 -m http.server 8000
# then open http://localhost:8000/docs-system.html
# OR: Node
npx serve design_handoff_nibiru_docs_system/source
# follow the printed URL
# OR: PHP (since this is a PHP project)
cd design_handoff_nibiru_docs_system/source
php -S localhost:8000
# then open http://localhost:8000/docs-system.html
```
If you just want to **look** at the design without a server, use `preview/nibiru-docs-system.html` — it's a single self-contained file with everything bundled in.
### Migration tips
- **If using Nextra**: Override the theme via `theme.config.tsx` and inject `tokens.css` + `components.css` as global styles. Use Nextra's MDX components (`<Callout>`, `<Tabs>`, etc.) and re-skin them with our class names.
- **If using Mintlify**: Customize via `mint.json` `colors` section + a custom CSS file. Most components in this design map 1:1 to Mintlify primitives.
- **If using VitePress**: Override default theme components in `.vitepress/theme/`. The sidebar/TOC layout maps directly.
- **If rolling your own**: Copy both CSS files into `app/globals.css` (or equivalent), build React/Vue/Svelte components matching the structure in the JSX files, render MDX with a custom component map.
## Open Questions for the Developer
1. **Theme persistence**: How should we remember the user's theme choice? `localStorage` is the prototype's assumption.
2. **Search backend**: The modal is a UI shell — needs a search index. Algolia DocSearch / Mintlify Search / a local index (FlexSearch, Pagefind) all work.
3. **Syntax highlighting**: We define the token classes; you wire them up. Shiki with a custom theme matching our `.tk-*` classes is recommended.
4. **i18n**: The locale dropdown is decorative in the prototype. Wire it up if/when translations exist.
5. **Mobile breakpoints**: Not heavily specified. Suggested: hide right TOC below 1100px, swap left sidebar for the mobile drawer below 768px.

View File

@@ -0,0 +1,758 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Documentation page · Nibiru — design mockup</title>
<meta name="description" content="Design mockup for a typical Nibiru documentation page. Layout, type, components and surfaces — built on the v4 Cosmos design system.">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0a0414">
<!-- Type: Inter Tight (body) + Space Grotesk (display) + JetBrains Mono -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@200..800&family=Space+Grotesk:wght@300..700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Canonical Nibiru tokens (also embedded inline below for portability) -->
<link rel="stylesheet" href="./tokens.css">
<style>
/* ============================================================================
* Documentation-page mockup — Nibiru v4 Cosmos
*
* Standalone HTML the designer can open in a browser and riff on. Mirrors
* the production stack (Astro + Starlight) at the visual layer: header on
* top, sidebar nav on the left, article in the middle, on-page TOC on
* the right. Pure CSS, no JS — open it with file:// and it just works.
*
* Hand off: the designer should treat this as the "before" baseline for
* doc pages. The startpage (separate file) sets the brand tone; this
* shows how that tone translates to a content-heavy reading layout.
* ========================================================================= */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: 'Inter Tight', ui-sans-serif, system-ui, sans-serif;
background: var(--nibiru-space, #0a0414);
background-image:
radial-gradient(ellipse 70% 50% at 0% 0%, rgba(184, 107, 255, 0.10), transparent 60%),
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(91, 141, 255, 0.07), transparent 60%);
background-attachment: fixed;
color: var(--nibiru-star, #f4eedb);
font-size: 16px;
line-height: 1.55;
font-feature-settings: "ss01", "cv11";
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a { color: inherit; text-decoration: none; }
::selection { background: rgba(184, 107, 255, 0.4); color: var(--nibiru-star, #f4eedb); }
.mono, code, kbd, pre {
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
}
.display { font-family: 'Space Grotesk', 'Inter Tight', ui-sans-serif, sans-serif; }
/* ============== HEADER ============== */
.doc-header {
position: sticky; top: 0; z-index: 60;
height: 64px;
display: flex; align-items: center;
padding: 0 24px;
background: rgba(6, 3, 15, 0.72);
border-bottom: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
backdrop-filter: saturate(150%) blur(14px);
-webkit-backdrop-filter: saturate(150%) blur(14px);
}
.doc-header-row {
display: flex; align-items: center; justify-content: space-between;
width: 100%;
gap: 24px;
}
.brand {
display: flex; align-items: center; gap: 10px; flex: none;
font-family: 'Space Grotesk', sans-serif;
}
.brand-mark {
width: 28px; height: 28px;
border-radius: 50%;
background:
radial-gradient(circle at 35% 35%, #f4eedb 0%, #b86bff 40%, #5b8dff 80%, #1c0f3a 100%);
box-shadow: 0 0 12px rgba(184, 107, 255, 0.5);
}
.brand-name {
font-size: 18px; font-weight: 500; letter-spacing: -0.02em;
color: var(--nibiru-star, #f4eedb);
}
.brand-name em { font-style: normal; font-weight: 300; }
.nav-version {
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.04em;
color: var(--nibiru-muted, #6e6680);
padding: 3px 7px;
border: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
border-radius: 999px;
margin-left: 6px;
display: inline-flex; align-items: center; gap: 6px;
}
.nav-version .dot {
width: 5px; height: 5px; border-radius: 50%;
background: #7ad6a3; box-shadow: 0 0 8px rgba(122, 214, 163, 0.7);
}
.doc-nav {
display: flex; gap: 24px; align-items: center;
flex: 1; justify-content: center;
}
.doc-nav a {
font-size: 14px;
color: rgba(244, 238, 219, 0.7);
transition: color 160ms ease;
}
.doc-nav a:hover, .doc-nav a[aria-current="page"] {
color: var(--nibiru-star, #f4eedb);
}
.search-inline {
display: flex; align-items: center; gap: 10px;
min-width: 220px;
height: 36px;
padding: 0 12px;
background: rgba(244, 238, 219, 0.04);
border: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
border-radius: 999px;
color: rgba(244, 238, 219, 0.55);
font-size: 13px;
transition: border-color 160ms ease, background-color 160ms ease;
}
.search-inline:hover { border-color: var(--nibiru-line-strong, rgba(244, 238, 219, 0.28)); }
.search-inline svg { width: 14px; height: 14px; flex: none; }
.search-inline kbd {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--nibiru-muted, #6e6680);
padding: 2px 6px;
border: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
border-radius: 4px;
background: rgba(244, 238, 219, 0.03);
}
.lang-switcher {
display: flex; gap: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
}
.lang-switcher a { padding: 4px 8px; border-radius: 6px; }
.lang-switcher a[aria-current="true"] {
color: var(--nibiru-star, #f4eedb);
background: rgba(184, 107, 255, 0.12);
}
/* ============== LAYOUT SHELL ============== */
.doc-shell {
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 240px;
gap: 0;
max-width: 1400px;
margin: 0 auto;
min-height: calc(100vh - 64px);
}
@media (max-width: 1100px) {
.doc-shell { grid-template-columns: 240px 1fr; }
.doc-toc { display: none; }
}
@media (max-width: 768px) {
.doc-shell { grid-template-columns: 1fr; }
.doc-sidebar { display: none; }
}
/* ============== SIDEBAR ============== */
.doc-sidebar {
position: sticky; top: 64px;
height: calc(100vh - 64px);
overflow-y: auto;
padding: 28px 16px 28px 24px;
border-right: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
background: rgba(10, 4, 20, 0.4);
backdrop-filter: blur(8px);
font-size: 14px;
scrollbar-width: thin;
scrollbar-color: rgba(184, 107, 255, 0.3) transparent;
}
.doc-sidebar h3 {
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
margin: 22px 0 8px;
font-weight: 500;
}
.doc-sidebar h3:first-child { margin-top: 0; }
.doc-sidebar ul {
list-style: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 1px;
}
.doc-sidebar a {
display: block;
padding: 7px 12px;
color: rgba(244, 238, 219, 0.7);
border-left: 1.5px solid transparent;
border-radius: 0 8px 8px 0;
transition: color 160ms ease, border-color 160ms ease, background-color 160ms ease;
}
.doc-sidebar a:hover {
color: var(--nibiru-star, #f4eedb);
border-left-color: var(--nibiru-iris-soft, #d4b4ff);
background: rgba(184, 107, 255, 0.06);
}
.doc-sidebar a[aria-current="page"] {
color: var(--nibiru-star, #f4eedb);
font-weight: 500;
border-left-color: var(--nibiru-nebula-mag, #b86bff);
background: rgba(184, 107, 255, 0.10);
}
/* ============== ARTICLE ============== */
.doc-article {
min-width: 0;
padding: 56px 64px 120px;
max-width: 800px;
margin: 0 auto;
}
@media (max-width: 768px) {
.doc-article { padding: 32px 24px 80px; }
}
.breadcrumbs {
display: flex; align-items: center; gap: 8px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--nibiru-muted, #6e6680);
margin-bottom: 18px;
flex-wrap: wrap;
}
.breadcrumbs a:hover { color: var(--nibiru-star, #f4eedb); }
.breadcrumbs .sep { color: var(--nibiru-line-strong, rgba(244, 238, 219, 0.28)); }
.doc-article h1 {
font-family: 'Space Grotesk', sans-serif;
font-size: clamp(2rem, 1.5rem + 1.6vw, 2.75rem);
line-height: 1.05;
letter-spacing: -0.04em;
font-weight: 400;
margin: 0 0 18px;
color: var(--nibiru-star, #f4eedb);
}
.doc-article h1 em {
font-style: normal; font-weight: 500;
background: linear-gradient(110deg, #ffb574 0%, #b86bff 70%);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
.doc-lede {
font-size: 1.18rem;
line-height: 1.55;
color: rgba(244, 238, 219, 0.72);
max-width: 56ch;
margin: 0 0 36px;
}
.doc-meta {
display: flex; gap: 28px; align-items: center;
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
padding-bottom: 28px;
margin-bottom: 36px;
border-bottom: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
flex-wrap: wrap;
}
.doc-meta strong { color: rgba(244, 238, 219, 0.85); font-weight: 500; }
.doc-meta .pulse {
width: 6px; height: 6px; border-radius: 50%;
background: #7ad6a3;
box-shadow: 0 0 8px rgba(122, 214, 163, 0.7);
}
.doc-article h2 {
font-family: 'Space Grotesk', sans-serif;
font-size: 1.6rem;
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1.15;
margin: 56px 0 16px;
padding-top: 24px;
border-top: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
color: var(--nibiru-star, #f4eedb);
}
.doc-article h3 {
font-family: 'Space Grotesk', sans-serif;
font-size: 1.18rem;
font-weight: 500;
letter-spacing: -0.02em;
margin: 36px 0 12px;
color: var(--nibiru-iris-soft, #d4b4ff);
}
.doc-article h4 {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
margin: 28px 0 10px;
font-weight: 500;
}
.doc-article p {
color: rgba(244, 238, 219, 0.85);
margin: 0 0 18px;
line-height: 1.65;
}
.doc-article strong { color: var(--nibiru-star, #f4eedb); font-weight: 600; }
.doc-article a {
color: var(--nibiru-iris-soft, #d4b4ff);
border-bottom: 1px solid rgba(184, 107, 255, 0.4);
transition: color 200ms ease, border-color 200ms ease;
}
.doc-article a:hover {
color: var(--nibiru-star, #f4eedb);
border-bottom-color: var(--nibiru-nebula-mag, #b86bff);
}
.doc-article ul, .doc-article ol {
margin: 0 0 18px; padding-left: 24px;
}
.doc-article li { margin: 6px 0; color: rgba(244, 238, 219, 0.85); }
/* ============== INLINE CODE ============== */
.doc-article :not(pre) > code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.86em;
background: rgba(184, 107, 255, 0.14);
color: var(--nibiru-iris-soft, #d4b4ff);
padding: 0.05em 0.4em;
border-radius: 6px;
border: 1px solid rgba(184, 107, 255, 0.20);
letter-spacing: 0.04em;
}
/* ============== CODE BLOCK ============== */
.code-block {
background: var(--nibiru-code-bg, #050208);
border: 1px solid var(--nibiru-line-strong, rgba(244, 238, 219, 0.28));
border-radius: 14px;
overflow: hidden;
margin: 22px 0;
box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.7);
}
.code-block-head {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 16px;
border-bottom: 1px solid rgba(244, 238, 219, 0.08);
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.10em; text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
}
.code-block-head .file { color: var(--nibiru-nebula-mag, #b86bff); }
.code-block-head .copy {
background: transparent;
border: 1px solid rgba(244, 238, 219, 0.12);
color: rgba(244, 238, 219, 0.7);
padding: 4px 10px;
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.10em;
cursor: pointer;
}
.code-block-head .copy:hover { border-color: var(--nibiru-iris-soft, #d4b4ff); color: var(--nibiru-star, #f4eedb); }
.code-block pre {
margin: 0; padding: 18px 20px;
font-family: 'JetBrains Mono', monospace;
font-size: 13px; line-height: 1.7;
color: var(--nibiru-star, #f4eedb);
overflow-x: auto;
}
.tk-c { color: #6e6680; font-style: italic; }
.tk-k { color: #c8a8ff; }
.tk-cls { color: #f0d4ff; }
.tk-fn { color: #b8d4ff; }
.tk-s { color: #ffd9a3; }
.tk-v { color: #ffb4d8; }
.tk-attr { color: #d4baff; }
.tk-n { color: #b8e0c2; }
/* ============== CALLOUT / ASIDE ============== */
.callout {
background: rgba(184, 107, 255, 0.06);
border: 1px solid rgba(244, 238, 219, 0.10);
border-left: 3px solid var(--nibiru-nebula-mag, #b86bff);
padding: 14px 16px 14px 18px;
border-radius: 0 10px 10px 0;
margin: 22px 0;
}
.callout--tip { border-left-color: var(--nibiru-nebula-amber, #ffb574); background: rgba(255, 181, 116, 0.06); }
.callout--danger { border-left-color: #ff8a9d; background: rgba(255, 138, 157, 0.06); }
.callout-title {
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.10em; text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
margin-bottom: 6px;
font-weight: 500;
}
.callout p { margin: 0; }
/* ============== TABLE ============== */
.doc-article table {
width: 100%;
border-collapse: collapse;
margin: 22px 0;
font-size: 14px;
}
.doc-article thead {
border-bottom: 2px solid var(--nibiru-line-strong, rgba(244, 238, 219, 0.28));
}
.doc-article th {
text-align: left;
padding: 10px 12px 10px 0;
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
font-size: 11px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
}
.doc-article td {
padding: 10px 12px 10px 0;
border-bottom: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
color: rgba(244, 238, 219, 0.85);
vertical-align: top;
}
/* ============== ON-PAGE TOC (right rail) ============== */
.doc-toc {
position: sticky; top: 64px;
height: calc(100vh - 64px);
overflow-y: auto;
padding: 56px 24px 56px 16px;
border-left: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
font-size: 13px;
}
.doc-toc h4 {
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
margin: 0 0 12px;
font-weight: 500;
}
.doc-toc ul {
list-style: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 4px;
}
.doc-toc a {
color: rgba(244, 238, 219, 0.6);
display: block;
padding: 4px 0;
transition: color 160ms ease;
line-height: 1.4;
}
.doc-toc a:hover { color: var(--nibiru-star, #f4eedb); }
.doc-toc a.active {
color: var(--nibiru-star, #f4eedb);
border-left: 2px solid var(--nibiru-nebula-mag, #b86bff);
margin-left: -10px;
padding-left: 8px;
}
.doc-toc .l2 { padding-left: 12px; font-size: 12px; }
/* ============== PAGE FOOTER (prev / next) ============== */
.page-pagination {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 80px;
padding-top: 36px;
border-top: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
}
@media (max-width: 600px) {
.page-pagination { grid-template-columns: 1fr; }
}
.page-pagination a {
background: var(--nibiru-night, #120825);
border: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
border-radius: 14px;
padding: 16px 18px;
transition: border-color 200ms, transform 200ms;
}
.page-pagination a:hover {
border-color: var(--nibiru-nebula-mag, #b86bff);
transform: translateY(-1px);
}
.page-pagination .lbl {
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
display: block;
margin-bottom: 4px;
}
.page-pagination .ttl {
font-size: 15px; font-weight: 500;
color: var(--nibiru-star, #f4eedb);
}
.page-pagination .next { text-align: right; }
/* ============== HELP STRIP ============== */
.help-strip {
margin-top: 48px;
padding: 18px 22px;
background: rgba(244, 238, 219, 0.03);
border: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
border-radius: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
font-size: 14px;
}
.help-strip-text { color: rgba(244, 238, 219, 0.75); }
.help-strip-actions { display: flex; gap: 8px; }
.help-strip-actions a {
padding: 7px 14px;
border: 1px solid var(--nibiru-line-strong, rgba(244, 238, 219, 0.28));
border-radius: 999px;
font-size: 13px;
color: rgba(244, 238, 219, 0.85);
transition: color 160ms, border-color 160ms;
border-bottom: 1px solid var(--nibiru-line-strong, rgba(244, 238, 219, 0.28));
}
.help-strip-actions a:hover {
color: var(--nibiru-star, #f4eedb);
border-color: var(--nibiru-iris-soft, #d4b4ff);
}
</style>
</head>
<body>
<!-- ====================== HEADER ====================== -->
<header class="doc-header">
<div class="doc-header-row">
<a class="brand" href="#">
<span class="brand-mark"></span>
<span class="brand-name">Nibiru<em> docs</em></span>
<span class="nav-version"><span class="dot"></span>v0.9.2</span>
</a>
<nav class="doc-nav" aria-label="Primary">
<a href="#" aria-current="page">Docs</a>
<a href="#">MMVC</a>
<a href="#">AI module</a>
<a href="#">CLI</a>
<a href="#">Showcase</a>
</nav>
<div class="doc-header-tools" style="display: flex; align-items: center; gap: 12px;">
<div class="search-inline" role="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-3-3"/></svg>
<span>Search the docs</span>
<kbd>⌘ K</kbd>
</div>
<div class="lang-switcher" aria-label="Language">
<a href="#" aria-current="true">EN</a>
<a href="#">DE</a>
<a href="#">JA</a>
<a href="#">ES</a>
<a href="#">FR</a>
</div>
</div>
</div>
</header>
<!-- ====================== SHELL ====================== -->
<div class="doc-shell">
<!-- ====== SIDEBAR ====== -->
<aside class="doc-sidebar" aria-label="Section navigation">
<h3>Get started</h3>
<ul>
<li><a href="#">What is Nibiru?</a></li>
<li><a href="#">Installation</a></li>
<li><a href="#">Quick start</a></li>
<li><a href="#" aria-current="page">Project structure</a></li>
<li><a href="#">Run it locally</a></li>
<li><a href="#">Deployment</a></li>
</ul>
<h3>The framework</h3>
<ul>
<li><a href="#">Architecture (MMVC)</a></li>
<li><a href="#">Bootstrap &amp; Dispatcher</a></li>
<li><a href="#">Routing</a></li>
<li><a href="#">Controllers</a></li>
<li><a href="#">Views &amp; Smarty</a></li>
<li><a href="#">Models</a></li>
<li><a href="#">Modules</a></li>
<li><a href="#">Forms</a></li>
<li><a href="#">Database &amp; Migrations</a></li>
<li><a href="#">Auth</a></li>
</ul>
<h3>AI in Nibiru</h3>
<ul>
<li><a href="#">The AI module</a></li>
<li><a href="#">Chat plugin</a></li>
<li><a href="#">Embed plugin</a></li>
<li><a href="#">RAG plugin</a></li>
<li><a href="#">Agent plugin</a></li>
<li><a href="#">Ask the Oracle</a></li>
</ul>
</aside>
<!-- ====== ARTICLE ====== -->
<main class="doc-article">
<nav class="breadcrumbs" aria-label="Breadcrumb">
<a href="#">Docs</a>
<span class="sep">/</span>
<a href="#">Get started</a>
<span class="sep">/</span>
<span>Project structure</span>
</nav>
<h1>Project <em>structure</em></h1>
<p class="doc-lede">Where Nibiru puts things, and why. The shape on disk maps to the four MMVC roles — read top-down and the framework will explain itself.</p>
<div class="doc-meta">
<span><span class="pulse"></span> <strong>Stable</strong> · v0.9.2</span>
<span>Updated <strong>2 days ago</strong></span>
<span>Reading time <strong>~ 6 min</strong></span>
<span>Edit on <strong>GitHub</strong></span>
</div>
<p>A fresh Nibiru install is just a Composer-managed PHP project with a single CLI binary, an <code>application/</code> tree for your code, and a <code>core/</code> tree for the framework itself. Everything else — Smarty's compiled templates, the public docroot, the migrations history — falls out of those four conventions.</p>
<h2 id="overview">Overview</h2>
<p>Open the repo and you'll see this:</p>
<div class="code-block">
<div class="code-block-head">
<span class="file">tree.txt</span>
<button class="copy" type="button">Copy</button>
</div>
<pre><span class="tk-c"># A Nibiru project, top level</span>
my-app/
├── application/ <span class="tk-c">// your code lives here</span>
│ ├── controller/
│ ├── module/
│ ├── view/
│ └── settings/
├── core/ <span class="tk-c">// the framework — don't edit</span>
├── public/ <span class="tk-c">// docroot — point your vhost here</span>
├── nibiru <span class="tk-c">// the CLI binary</span>
├── composer.json
└── index.php <span class="tk-c">// front controller</span></pre>
</div>
<h3 id="application">application/</h3>
<p>Your code. Each role in <strong>MMVC</strong> is its own subdirectory: <code>controller/</code>, <code>module/</code>, <code>view/</code>. The fourth — Models — live inside their own <code>module/&lt;name&gt;/models/</code> subtrees, because in MMVC every module owns the data shape it touches.</p>
<div class="callout callout--tip">
<div class="callout-title">Tip</div>
<p>Run <code>./nibiru -c products</code> and the CLI will scaffold the controller, the module, the navigation entry and a Smarty template into the right places automatically.</p>
</div>
<h3 id="core">core/</h3>
<p>The framework itself. <strong>Don't edit it.</strong> If you find yourself reaching in here, the answer is almost always to extend a class in <code>application/</code> instead. The single exception: <a href="#">contributing back</a>.</p>
<h3 id="public">public/</h3>
<p>Your vhost docroot. Static assets are served directly from this directory; everything else falls through to the front controller via <code>index.php</code>. The <code>.htaccess</code> in the project root takes care of rewrites for Apache; the included <a href="#"><code>vhost.conf</code></a> shows the equivalent for nginx.</p>
<h2 id="naming">Naming conventions</h2>
<p>Every controller is <code>&lt;name&gt;Controller.php</code>, every module sits at <code>application/module/&lt;name&gt;/</code>, every Smarty template lives at <code>application/view/templates/&lt;name&gt;.tpl</code>. The CLI enforces this — you don't have to remember it.</p>
<table>
<thead>
<tr>
<th>Path</th>
<th>Created by</th>
<th>Owns</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>application/controller/</code></td>
<td><code>./nibiru -c &lt;name&gt;</code></td>
<td>Routing, dispatch, lifecycle</td>
</tr>
<tr>
<td><code>application/module/</code></td>
<td><code>./nibiru -m &lt;name&gt;</code></td>
<td>Capabilities, plugins, models</td>
</tr>
<tr>
<td><code>application/view/templates/</code></td>
<td>scaffolded with controller</td>
<td>Smarty 3 templates</td>
</tr>
<tr>
<td><code>application/settings/migrations/</code></td>
<td><code>./nibiru -mi-add &lt;n&gt;</code></td>
<td>Timestamped SQL migrations</td>
</tr>
</tbody>
</table>
<div class="callout">
<div class="callout-title">Note</div>
<p>Smarty templates compile to <code>application/view/templates_c/</code> at first request. That directory is gitignored — clear it with <code>./nibiru -cache-clear</code> if a template ever sticks.</p>
</div>
<h2 id="next">Next</h2>
<p>You know where things go. Now wire one up — the <a href="#">Quick start</a> takes you from an empty project to a running controller in five commands.</p>
<!-- prev / next -->
<nav class="page-pagination" aria-label="Page navigation">
<a class="prev" href="#">
<span class="lbl">← Previous</span>
<span class="ttl">Quick start</span>
</a>
<a class="next" href="#">
<span class="lbl">Next →</span>
<span class="ttl">Run it locally</span>
</a>
</nav>
<!-- help strip -->
<aside class="help-strip">
<span class="help-strip-text">Was this page helpful?</span>
<div class="help-strip-actions">
<a href="#">Yes</a>
<a href="#">Suggest an edit</a>
</div>
</aside>
</main>
<!-- ====== ON-PAGE TOC ====== -->
<aside class="doc-toc" aria-label="On this page">
<h4>On this page</h4>
<ul>
<li><a href="#overview" class="active">Overview</a></li>
<li><a href="#application" class="l2">application/</a></li>
<li><a href="#core" class="l2">core/</a></li>
<li><a href="#public" class="l2">public/</a></li>
<li><a href="#naming">Naming conventions</a></li>
<li><a href="#next">Next</a></li>
</ul>
<h4 style="margin-top: 36px;">Notes</h4>
<p style="font-size: 12px; color: var(--nibiru-muted, #6e6680); line-height: 1.5;">
This is a design mockup, not a real docs page. The TOC scroll-spy, search modal,
and language-switcher are wired-up in the production Astro/Starlight build —
treat the visual elements here as the brief.
</p>
</aside>
</div>
</body>
</html>

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,936 @@
// DesignCanvas.jsx — Figma-ish design canvas wrapper
// Warm gray grid bg + Sections + Artboards + PostIt notes.
// Artboards are reorderable (grip-drag), deletable, labels/titles are
// inline-editable, and any artboard can be opened in a fullscreen focus
// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar
// via the host bridge. No assets, no deps.
//
// Usage:
// <DesignCanvas>
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
// </DCSection>
// </DesignCanvas>
const DC = {
bg: '#f0eee9',
grid: 'rgba(0,0,0,0.06)',
label: 'rgba(60,50,40,0.7)',
title: 'rgba(40,30,20,0.85)',
subtitle: 'rgba(60,50,40,0.6)',
postitBg: '#fef4a8',
postitText: '#5a4a2a',
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
};
// One-time CSS injection (classes are dc-prefixed so they don't collide with
// the hosted design's own styles).
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
const s = document.createElement('style');
s.id = 'dc-styles';
s.textContent = [
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
// isolation:isolate contains artboard content's z-indexes so a
// z-indexed child (sticky navbar etc.) can't paint over .dc-header or
// the .dc-menu popover that drops into the top of the card.
'.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}',
'.dc-card *{scrollbar-width:none}',
'.dc-card *::-webkit-scrollbar{display:none}',
// Per-artboard header: grip + label on the left, delete/expand on the
// right. Single flex row; when the artboard's on-screen width is too
// narrow for both the label yields (ellipsis, then hidden entirely below
// ~4ch via the container query) and the buttons stay on the row.
'.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;',
' display:flex;align-items:center;container-type:inline-size}',
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}',
'.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}',
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
'.dc-grip:active{cursor:grabbing}',
'.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;',
' display:flex;align-items:center;transition:background .12s;overflow:hidden}',
// Below ~4ch of label room: hide the label entirely, and drop the grip to
// hover-only (same reveal rule as .dc-btns) so a narrow header is clean
// until the card is moused.
'@container (max-width: 110px){',
' .dc-labeltext{display:none}',
' .dc-grip{opacity:0}',
' [data-dc-slot]:hover .dc-grip{opacity:1}',
'}',
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
'.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}',
'.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}',
'.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}',
'[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}',
'.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;',
' font:inherit;transition:background .12s,color .12s}',
'.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}',
// Slot hosting an open menu floats above later siblings (which otherwise
// paint on top — same z-index:auto, later DOM order) so the popup isn't
// clipped by the next card.
'[data-dc-slot]:has(.dc-menu){z-index:10}',
'.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;',
' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}',
'.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;',
' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;',
' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}',
'.dc-menu button:hover{background:rgba(0,0,0,.05)}',
'.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}',
'.dc-menu .dc-danger{color:#c96442}',
'.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}',
// Chrome (titles / labels / buttons) counter-scales against the viewport
// zoom so it stays a constant on-screen size. --dc-inv-zoom is set by
// DCViewport on every transform update and inherits to all descendants —
// any overlay inside the world (e.g. a TweaksPanel on an artboard) can use
// it the same way.
//
// The header uses transform:scale (out-of-flow, so layout impact doesn't
// matter) with its world-space width set to card-width / inv-zoom so that
// after counter-scaling its on-screen width exactly matches the card's —
// that's what lets the container query + text-overflow behave against the
// card's visible edge at every zoom level.
//
// The section head uses CSS zoom instead of transform so its layout box
// grows with the counter-scale, pushing the card row down — otherwise the
// constant-screen-size title would overflow into the (shrinking) world-
// space gap and overlap the artboard headers at low zoom.
'.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));',
' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}',
'.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}',
].join('\n');
document.head.appendChild(s);
}
const DCCtx = React.createContext(null);
// ─────────────────────────────────────────────────────────────
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
// Owns runtime state (per-section order, renamed titles/labels, hidden
// artboards, focused artboard). Order/titles/labels/hidden persist to a
// .design-canvas.state.json
// sidecar next to the HTML. Reads go via plain fetch() so the saved
// arrangement is visible anywhere the HTML + sidecar are served together
// (omelette preview, direct link, downloaded zip). Writes go through the
// host's window.omelette bridge — editing requires the omelette runtime.
// Focus is ephemeral.
// ─────────────────────────────────────────────────────────────
const DC_STATE_FILE = '.design-canvas.state.json';
function DesignCanvas({ children, minScale, maxScale, style }) {
const [state, setState] = React.useState({ sections: {}, focus: null });
// Hold rendering until the sidecar read settles so the saved order/titles
// appear on first paint (no source-order flash). didRead gates writes until
// the read settles so the empty initial state can't clobber a slow read;
// skipNextWrite suppresses the one echo-write that would otherwise follow
// hydration.
const [ready, setReady] = React.useState(false);
const didRead = React.useRef(false);
const skipNextWrite = React.useRef(false);
React.useEffect(() => {
let off = false;
fetch('./' + DC_STATE_FILE)
.then((r) => (r.ok ? r.json() : null))
.then((saved) => {
if (off || !saved || !saved.sections) return;
skipNextWrite.current = true;
setState((s) => ({ ...s, sections: saved.sections }));
})
.catch(() => {})
.finally(() => { didRead.current = true; if (!off) setReady(true); });
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
return () => { off = true; clearTimeout(t); };
}, []);
React.useEffect(() => {
if (!didRead.current) return;
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
const t = setTimeout(() => {
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
}, 250);
return () => clearTimeout(t);
}, [state.sections]);
// Build registries synchronously from children so FocusOverlay can read
// them in the same render. Only direct DCSection > DCArtboard children are
// walked — wrapping them in other elements opts out of focus/reorder.
const registry = {}; // slotId -> { sectionId, artboard }
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
const sectionOrder = [];
React.Children.forEach(children, (sec) => {
if (!sec || sec.type !== DCSection) return;
const sid = sec.props.id ?? sec.props.title;
if (!sid) return;
sectionOrder.push(sid);
const persisted = state.sections[sid] || {};
const abs = [];
React.Children.forEach(sec.props.children, (ab) => {
if (!ab || ab.type !== DCArtboard) return;
const aid = ab.props.id ?? ab.props.label;
if (aid) abs.push([aid, ab]);
});
// hidden is scoped to one source revision — when the agent regenerates
// (artboard-ID set changes), prior deletes don't apply to new content.
const srcKey = abs.map(([k]) => k).join('\x1f');
const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : [];
const srcIds = [];
abs.forEach(([aid, ab]) => {
if (hidden.includes(aid)) return;
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
srcIds.push(aid);
});
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
sectionMeta[sid] = {
title: persisted.title ?? sec.props.title,
subtitle: sec.props.subtitle,
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
};
});
const api = React.useMemo(() => ({
state,
section: (id) => state.sections[id] || {},
patchSection: (id, p) => setState((s) => ({
...s,
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
})),
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
}), [state]);
// Esc exits focus; any outside pointerdown commits an in-progress rename.
React.useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
const onPd = (e) => {
const ae = document.activeElement;
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
};
document.addEventListener('keydown', onKey);
document.addEventListener('pointerdown', onPd, true);
return () => {
document.removeEventListener('keydown', onKey);
document.removeEventListener('pointerdown', onPd, true);
};
}, [api]);
return (
<DCCtx.Provider value={api}>
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
{state.focus && registry[state.focus] && (
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
)}
</DCCtx.Provider>
);
}
// ─────────────────────────────────────────────────────────────
// DCViewport — transform-based pan/zoom (internal)
//
// Input mapping (Figma-style):
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
// • trackpad scroll → pan (two-finger)
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
// • middle-drag / primary-drag-on-bg → pan
//
// Transform state lives in a ref and is written straight to the DOM
// (translate3d + will-change) so wheel ticks don't go through React —
// keeps pans at 60fps on dense canvases.
// ─────────────────────────────────────────────────────────────
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
const vpRef = React.useRef(null);
const worldRef = React.useRef(null);
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
// Persist viewport across reloads so the user lands back where they were
// after an agent edit or browser refresh. The sandbox origin is already
// per-project; pathname keeps multiple canvas files in one project apart.
const tfKey = 'dc-viewport:' + location.pathname;
const saveT = React.useRef(0);
const lastPostedScale = React.useRef();
const apply = React.useCallback(() => {
const { x, y, scale } = tf.current;
const el = worldRef.current;
if (!el) return;
el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
// Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel).
el.style.setProperty('--dc-inv-zoom', String(1 / scale));
// Keep the host toolbar's % readout in sync with the canvas scale. Pan
// ticks leave scale unchanged — skip the cross-frame post for those.
if (lastPostedScale.current !== scale) {
lastPostedScale.current = scale;
window.parent.postMessage({ type: '__dc_zoom', scale }, '*');
}
clearTimeout(saveT.current);
saveT.current = setTimeout(() => {
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
}, 200);
}, [tfKey]);
React.useLayoutEffect(() => {
const flush = () => {
clearTimeout(saveT.current);
try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {}
};
try {
const s = JSON.parse(localStorage.getItem(tfKey) || 'null');
if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) {
tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) };
apply();
}
} catch {}
// Flush on pagehide and unmount so a reload within the 200ms debounce
// window doesn't drop the last pan/zoom.
window.addEventListener('pagehide', flush);
return () => { window.removeEventListener('pagehide', flush); flush(); };
}, []);
React.useEffect(() => {
const vp = vpRef.current;
if (!vp) return;
const zoomAt = (cx, cy, factor) => {
const r = vp.getBoundingClientRect();
const px = cx - r.left, py = cy - r.top;
const t = tf.current;
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
const k = next / t.scale;
// keep the world point under the cursor fixed
t.x = px - (px - t.x) * k;
t.y = py - (py - t.y) * k;
t.scale = next;
apply();
};
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
// line-mode deltas (Firefox) or large integer pixel deltas with no X
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
// two-finger scroll sends small/fractional pixel deltas, often with
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
const isMouseWheel = (e) =>
e.deltaMode !== 0 ||
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
const onWheel = (e) => {
e.preventDefault();
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {
// trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched
// wheels fall through to the fixed-step branch below.
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
} else if (isMouseWheel(e)) {
// notched mouse wheel — fixed-ratio step per click
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
} else {
// trackpad two-finger scroll — pan
tf.current.x -= e.deltaX;
tf.current.y -= e.deltaY;
apply();
}
};
// Safari sends native gesture* events for trackpad pinch with a smooth
// e.scale; preferring these over the ctrl+wheel fallback gives a much
// better feel there. No-ops on other browsers. Safari also fires
// ctrlKey wheel events during the same pinch — isGesturing makes
// onWheel drop those entirely so they neither zoom nor pan.
let gsBase = 1;
let isGesturing = false;
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
const onGestureChange = (e) => {
e.preventDefault();
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
};
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
// Drag-pan: middle button anywhere, or primary button on canvas
// background (anything that isn't an artboard or an inline editor).
let drag = null;
const onPointerDown = (e) => {
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
e.preventDefault();
vp.setPointerCapture(e.pointerId);
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
vp.style.cursor = 'grabbing';
};
const onPointerMove = (e) => {
if (!drag || e.pointerId !== drag.id) return;
tf.current.x += e.clientX - drag.lx;
tf.current.y += e.clientY - drag.ly;
drag.lx = e.clientX; drag.ly = e.clientY;
apply();
};
const onPointerUp = (e) => {
if (!drag || e.pointerId !== drag.id) return;
vp.releasePointerCapture(e.pointerId);
drag = null;
vp.style.cursor = '';
};
// Host-driven zoom (toolbar % menu). Zooms around viewport centre so the
// visible midpoint stays fixed — matching the host's iframe-zoom feel.
const onHostMsg = (e) => {
const d = e.data;
if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') {
const r = vp.getBoundingClientRect();
zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale);
} else if (d && d.type === '__dc_probe') {
// Host's [readyGen] reset asks whether a canvas is present; it
// fires on the iframe's native 'load', which for canvases with
// images/fonts is after our mount-time announce, so re-announce.
// Clear the pan-tick guard so apply() re-posts the current scale
// even if it's unchanged — the host just reset dcScale to 1.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
}
};
window.addEventListener('message', onHostMsg);
// Announce canvas mode so the host toolbar proxies its % control here
// instead of scaling the iframe element (which would just shrink the
// viewport window of an infinite canvas). The apply() that follows emits
// the initial __dc_zoom so the toolbar % is correct before first pinch.
// lastPostedScale reset mirrors the __dc_probe handler: the layout
// effect's restore-path apply() may already have posted the restored
// scale (before __dc_present), so clear the guard to re-post it in order.
window.parent.postMessage({ type: '__dc_present' }, '*');
lastPostedScale.current = undefined;
apply();
vp.addEventListener('wheel', onWheel, { passive: false });
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
vp.addEventListener('pointerdown', onPointerDown);
vp.addEventListener('pointermove', onPointerMove);
vp.addEventListener('pointerup', onPointerUp);
vp.addEventListener('pointercancel', onPointerUp);
return () => {
window.removeEventListener('message', onHostMsg);
vp.removeEventListener('wheel', onWheel);
vp.removeEventListener('gesturestart', onGestureStart);
vp.removeEventListener('gesturechange', onGestureChange);
vp.removeEventListener('gestureend', onGestureEnd);
vp.removeEventListener('pointerdown', onPointerDown);
vp.removeEventListener('pointermove', onPointerMove);
vp.removeEventListener('pointerup', onPointerUp);
vp.removeEventListener('pointercancel', onPointerUp);
};
}, [apply, minScale, maxScale]);
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
return (
<div
ref={vpRef}
className="design-canvas"
style={{
height: '100vh', width: '100vw',
background: DC.bg,
overflow: 'hidden',
overscrollBehavior: 'none',
touchAction: 'none',
position: 'relative',
fontFamily: DC.font,
boxSizing: 'border-box',
...style,
}}
>
<div
ref={worldRef}
style={{
position: 'absolute', top: 0, left: 0,
transformOrigin: '0 0',
willChange: 'transform',
width: 'max-content', minWidth: '100%',
minHeight: '100%',
padding: '60px 0 80px',
}}
>
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
{children}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// DCSection — editable title + h-row of artboards in persisted order
// ─────────────────────────────────────────────────────────────
function DCSection({ id, title, subtitle, children, gap = 48 }) {
const ctx = React.useContext(DCCtx);
const sid = id ?? title;
const all = React.Children.toArray(children);
const artboards = all.filter((c) => c && c.type === DCArtboard);
const rest = all.filter((c) => !(c && c.type === DCArtboard));
const sec = (ctx && sid && ctx.section(sid)) || {};
// Must match DesignCanvas's srcKey computation exactly (it filters falsy
// IDs), or onDelete persists a srcKey that DesignCanvas never recognizes.
const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean);
const srcKey = allIds.join('\x1f');
const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : [];
const srcOrder = allIds.filter((k) => !hidden.includes(k));
const order = React.useMemo(() => {
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
}, [sec.order, srcOrder.join('|')]);
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
// marginBottom counter-scales so the on-screen gap between sections stays
// constant — otherwise at low zoom the (world-space) gap collapses while
// the screen-constant sectionhead below it doesn't, and the title reads as
// belonging to the section above. paddingBottom below is just enough for
// the 24px artboard-header (abs-positioned above each card) plus ~8px, so
// the title sits tight against its own row at every zoom.
return (
<div data-dc-section={sid}
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
<div style={{ padding: '0 60px' }}>
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
<DCEditable tag="div" value={sec.title ?? title}
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
</div>
</div>
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
{order.map((k) => (
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
label={(sec.labels || {})[k] ?? byId[k].props.label}
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
onDelete={() => ctx && ctx.patchSection(sid, (x) => ({
hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k],
srcKey,
}))}
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
))}
</div>
{rest}
</div>
);
}
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
function DCArtboard() { return null; }
// Per-artboard export (kind: 'png' | 'html'). Both paths share the same
// self-contained clone: computed styles baked in, @font-face / <img> /
// inline-style background-image urls inlined as data URIs. PNG wraps the
// clone in foreignObject→canvas at 3× the artboard's natural width×height
// (same pipeline the host uses for page captures); HTML wraps it in a
// minimal standalone document. Both are independent of viewport zoom.
async function dcExport(node, w, h, name, kind) {
try { await document.fonts.ready; } catch {}
const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => {
const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b);
})).catch(() => url);
// Collect @font-face rules. ss.cssRules throws SecurityError on
// cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch
// the CSS text directly (those endpoints send ACAO:*) and regex-extract
// the blocks. @import and @media/@supports are walked so nested
// @font-face rules aren't missed.
const fontRules = [], pending = [], seen = new Set();
const scrapeCss = (href) => {
if (seen.has(href)) return; seen.add(href);
pending.push(fetch(href).then((r) => r.text()).then((css) => {
for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href });
for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g))
scrapeCss(new URL(m[1], href).href);
}).catch(() => {}));
};
const walk = (rules, base) => {
for (const r of rules) {
if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base });
else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) {
const ibase = r.styleSheet.href || base;
try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); }
} else if (r.cssRules) walk(r.cssRules, base);
}
};
for (const ss of document.styleSheets) {
const base = ss.href || location.href;
try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); }
}
while (pending.length) await pending.shift();
const fontCss = (await Promise.all(fontRules.map(async (rule) => {
let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g;
while ((m = re.exec(rule.css))) {
if (m[2].indexOf('data:') === 0) continue;
let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; }
out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")');
}
return out;
}))).join('\n');
const cloneStyled = (src) => {
if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode('');
const dst = src.cloneNode(false);
if (src.nodeType === 1) {
const cs = getComputedStyle(src); let txt = '';
for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';';
dst.setAttribute('style', txt + 'animation:none;transition:none;');
if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {}
}
for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c));
return dst;
};
const clone = cloneStyled(node);
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
// Drop the card's own shadow/radius so the export is a flush w×h rect;
// the artboard's own background (if any) is already in the computed style.
clone.style.boxShadow = 'none'; clone.style.borderRadius = '0';
const jobs = [];
clone.querySelectorAll('img').forEach((el) => {
const s = el.getAttribute('src');
if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d)));
});
[clone, ...clone.querySelectorAll('*')].forEach((el) => {
const bg = el.style.backgroundImage; if (!bg) return;
let m; const re = /url\(["']?([^"')]+)["']?\)/g;
while ((m = re.exec(bg))) {
const tok = m[0], url = m[1];
if (url.indexOf('data:') === 0) continue;
jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); }));
}
});
await Promise.all(jobs);
const xml = new XMLSerializer().serializeToString(clone);
const save = (blob, ext) => {
if (!blob) return;
const a = document.createElement('a');
a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
};
if (kind === 'html') {
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
(fontCss ? '<style>' + fontCss + '</style>' : '') +
'</head><body style="margin:0">' + xml + '</body></html>';
return save(new Blob([html], { type: 'text/html' }), 'html');
}
// PNG: the SVG's own width/height must be the output resolution — an
// <img>-loaded SVG rasterizes at its intrinsic size, so sizing it at 1×
// and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the
// w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders
// the HTML at full resolution.
const px = 3;
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
const img = new Image();
await new Promise((res, rej) => {
img.onload = res; img.onerror = () => rej(new Error('svg load failed'));
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
});
const cv = document.createElement('canvas');
cv.width = w * px; cv.height = h * px;
cv.getContext('2d').drawImage(img, 0, 0);
cv.toBlob((blob) => save(blob, 'png'), 'image/png');
}
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) {
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
const id = rawId ?? rawLabel;
const ref = React.useRef(null);
const cardRef = React.useRef(null);
const menuRef = React.useRef(null);
const [menuOpen, setMenuOpen] = React.useState(false);
const [confirming, setConfirming] = React.useState(false);
// ⋯ menu: close on any outside pointerdown. Two-click delete lives inside
// the menu — first click arms the row, second commits; closing disarms.
React.useEffect(() => {
if (!menuOpen) { setConfirming(false); return; }
const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); };
document.addEventListener('pointerdown', off, true);
return () => document.removeEventListener('pointerdown', off, true);
}, [menuOpen]);
const doExport = (kind) => {
setMenuOpen(false);
if (!cardRef.current) return;
const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_');
dcExport(cardRef.current, width, height, name, kind)
.catch((e) => console.error('[design-canvas] export failed:', e));
};
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
// their would-be slots in real time via transforms. DOM order only
// changes on drop.
const onGripDown = (e) => {
e.preventDefault(); e.stopPropagation();
const me = ref.current;
// translateX is applied in local (pre-scale) space but pointer deltas and
// getBoundingClientRect().left are screen-space — divide by the viewport's
// current scale so the dragged card tracks the cursor at any zoom level.
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
const slotXs = homes.map((h) => h.x);
const startIdx = order.indexOf(id);
const startX = e.clientX;
let liveOrder = order.slice();
me.classList.add('dc-dragging');
const layout = () => {
for (const h of homes) {
if (h.id === id) continue;
const slot = liveOrder.indexOf(h.id);
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
}
};
const move = (ev) => {
const dx = ev.clientX - startX;
me.style.transform = `translateX(${dx / scale}px)`;
const cur = homes[startIdx].x + dx;
let nearest = 0, best = Infinity;
for (let i = 0; i < slotXs.length; i++) {
const d = Math.abs(slotXs[i] - cur);
if (d < best) { best = d; nearest = i; }
}
if (liveOrder.indexOf(id) !== nearest) {
liveOrder = order.filter((k) => k !== id);
liveOrder.splice(nearest, 0, id);
layout();
}
};
const up = () => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', up);
const finalSlot = liveOrder.indexOf(id);
me.classList.remove('dc-dragging');
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
// After the settle transition, kill transitions + clear transforms +
// commit the reorder in the same frame so there's no visual snap-back.
setTimeout(() => {
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
requestAnimationFrame(() => requestAnimationFrame(() => {
for (const h of homes) h.el.style.transition = '';
}));
}, 180);
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', up);
};
return (
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
<div className="dc-header" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
<div className="dc-labelrow">
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
</div>
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
</div>
</div>
<div className="dc-btns">
<div ref={menuRef} style={{ position: 'relative' }}>
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
</button>
{menuOpen && (
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
<button onClick={() => doExport('png')}>Download PNG</button>
<button onClick={() => doExport('html')}>Download HTML</button>
<hr />
<button className="dc-danger"
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
{confirming ? 'Click again to delete' : 'Delete'}
</button>
</div>
)}
</div>
<button className="dc-expand" onClick={onFocus} title="Focus">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
</button>
</div>
</div>
<div ref={cardRef} className="dc-card"
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
</div>
</div>
);
}
// Inline rename — commits on blur or Enter.
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
const T = tag;
return (
<T className="dc-editable" contentEditable suppressContentEditableWarning
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
style={style}>{value}</T>
);
}
// ─────────────────────────────────────────────────────────────
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
// sections, Esc or backdrop click to exit.
// ─────────────────────────────────────────────────────────────
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
const ctx = React.useContext(DCCtx);
const { sectionId, artboard } = entry;
const sec = ctx.section(sectionId);
const meta = sectionMeta[sectionId];
const peers = meta.slotIds;
const aid = artboard.props.id ?? artboard.props.label;
const idx = peers.indexOf(aid);
const secIdx = sectionOrder.indexOf(sectionId);
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
const goSection = (d) => {
// Sections whose artboards are all deleted have slotIds:[] — step past
// them to the next non-empty section so ↑/↓ doesn't dead-end.
const n = sectionOrder.length;
for (let i = 1; i < n; i++) {
const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n];
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
if (first) { ctx.setFocus(`${ns}/${first}`); return; }
}
};
React.useEffect(() => {
const k = (e) => {
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
};
document.addEventListener('keydown', k);
return () => document.removeEventListener('keydown', k);
});
const { width = 260, height = 480, children } = artboard.props;
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
const [ddOpen, setDd] = React.useState(false);
const Arrow = ({ dir, onClick }) => (
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
</button>
);
// Portal to body so position:fixed is the real viewport regardless of any
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
return ReactDOM.createPortal(
<div onClick={() => ctx.setFocus(null)}
onWheel={(e) => e.preventDefault()}
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
fontFamily: DC.font, color: '#fff' }}>
{/* top bar: section dropdown (left) · close (right) */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
<div style={{ position: 'relative' }}>
<button onClick={() => setDd((o) => !o)}
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
</span>
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
</button>
{ddOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
{sectionMeta[sid].title}
</button>
))}
</div>
)}
</div>
<div style={{ flex: 1 }} />
<button onClick={() => ctx.setFocus(null)}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
</div>
{/* card centered, label + index below — only the card itself stops
propagation so any backdrop click (including the margins around
the card) exits focus */}
<div
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
</div>
</div>
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
{(sec.labels || {})[aid] ?? artboard.props.label}
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
</div>
</div>
<Arrow dir="left" onClick={() => go(-1)} />
<Arrow dir="right" onClick={() => go(1)} />
{/* dots */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
{peers.map((p, i) => (
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
))}
</div>
</div>,
document.body,
);
}
// ─────────────────────────────────────────────────────────────
// Post-it — absolute-positioned sticky note
// ─────────────────────────────────────────────────────────────
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
return (
<div style={{
position: 'absolute', top, left, right, bottom, width,
background: DC.postitBg, padding: '14px 16px',
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
transform: `rotate(${rotate}deg)`,
zIndex: 5,
}}>{children}</div>
);
}
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });

View File

@@ -0,0 +1,523 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nibiru — Docs Design System</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="docs-system/tokens.css" />
<link rel="stylesheet" href="docs-system/components.css" />
<style>
body {
margin: 0;
background: #050208;
font-family: "Inter Tight", system-ui, sans-serif;
color: #f4eedb;
}
</style>
</head>
<body>
<!-- Lotus logo as data URI (so the bundler can inline it & it works with file://) -->
<img id="lotus-asset" src="docs-system/assets/lotus.png" style="display:none" alt=""/>
<script>
// Convert the loaded image to a data URL once, expose globally for components.
(function () {
const img = document.getElementById('lotus-asset');
function publish() {
try {
const c = document.createElement('canvas');
c.width = img.naturalWidth || 861;
c.height = img.naturalHeight || 569;
c.getContext('2d').drawImage(img, 0, 0);
window.NIBIRU_LOTUS = c.toDataURL('image/png');
} catch (e) {
window.NIBIRU_LOTUS = img.src;
}
}
if (img.complete && img.naturalWidth) publish();
else img.addEventListener('load', publish, { once: true });
})();
</script>
<template id="__bundler_thumbnail" data-bg-color="#0b0410">
<svg viewBox="0 0 1200 800" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="bg" cx="50%" cy="50%" r="60%">
<stop offset="0%" stop-color="#1a0824"/>
<stop offset="100%" stop-color="#05020a"/>
</radialGradient>
<linearGradient id="petalP" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#d8a8ff"/>
<stop offset="100%" stop-color="#8a78c4"/>
</linearGradient>
<linearGradient id="petalB" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#bfd4ee"/>
<stop offset="100%" stop-color="#7ea0cc"/>
</linearGradient>
</defs>
<rect width="1200" height="800" fill="url(#bg)"/>
<g transform="translate(600 420)" opacity="0.95">
<ellipse cx="0" cy="-80" rx="38" ry="140" fill="url(#petalP)"/>
<ellipse cx="-130" cy="-30" rx="38" ry="140" fill="url(#petalP)" transform="rotate(-30 -130 -30)"/>
<ellipse cx="130" cy="-30" rx="38" ry="140" fill="url(#petalB)" transform="rotate(30 130 -30)"/>
<ellipse cx="-230" cy="40" rx="34" ry="120" fill="url(#petalP)" transform="rotate(-55 -230 40)"/>
<ellipse cx="230" cy="40" rx="34" ry="120" fill="url(#petalB)" transform="rotate(55 230 40)"/>
</g>
<text x="600" y="640" text-anchor="middle" font-family="system-ui, sans-serif" font-size="64" font-weight="300" fill="#d8a8ff" letter-spacing="2">Nibiru</text>
<text x="600" y="700" text-anchor="middle" font-family="system-ui, sans-serif" font-size="22" fill="#8ea8d4" letter-spacing="3">Docs Design System</text>
</svg>
</template>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="docs-system/components/navigation.jsx"></script>
<script type="text/babel" src="docs-system/components/typography-and-code.jsx"></script>
<script type="text/babel" src="docs-system/components/extras.jsx"></script>
<script type="text/babel" data-presets="react">
(() => {
const { DesignCanvas, DCSection, DCArtboard } = window;
// ============================================================
// Frame wrapper — gives each artboard the docs-frame baseline
// ============================================================
function Frame({ theme = "dark", padded = true, children, style }) {
return (
<div
className={"docs-frame " + (theme === "light" ? "theme-light" : "")}
style={{ width: "100%", height: "100%", ...style }}
>
<div style={{ background: "var(--space)", minHeight: "100%", height: "100%", padding: padded ? 32 : 0 }} className="cosmic-bg">
{children}
</div>
</div>
);
}
// ============================================================
// FULL PAGE PREVIEW (the marquee artboard)
// ============================================================
function FullPagePreview({ theme = "dark" }) {
const tocItems = [
{ id: "overview", level: 1, label: "Overview" },
{ id: "box", level: 1, label: "What's in the box" },
{ id: "mmvc", level: 1, label: "What MMVC actually means" },
{ id: "lifecycle", level: 1, label: "The request lifecycle" },
{ id: "for-whom", level: 1, label: "Who Nibiru is for" },
];
return (
<div className={"docs-frame " + (theme === "light" ? "theme-light" : "")} style={{ height: "100%" }}>
<div className="docs-page-preview-shell cosmic-bg">
<TopNav theme={theme} />
<div className="docs-page-preview-body">
<Sidebar />
<div className="docs-page-preview-content">
<PageHeader
crumbs={["Get Started", "What is Nibiru?"]}
title="What is Nibiru?"
summary="A modular MVC PHP framework — MMVC — built for rapid prototyping without giving up the discipline of a real framework."
lastUpdated="2 days ago"
/>
<Prose>
<h2 id="overview">Overview</h2>
<p>
<strong>Nibiru</strong> is a modular <em>MMVC</em> framework Model, <strong>Module</strong>, View, Controller small enough to fit in your head, powerful enough to back production apps.
</p>
<p>
The name is a wink at Babylonian astronomy: <strong>Nibiru</strong> was the celestial crossing-point associated with Marduk. The framework runs on the same idea a single point through which your modules, controllers, views and data cross paths.
</p>
<h2 id="box">What's in the box</h2>
<DocTable
headers={["Layer", "Description"]}
rows={[
["Routing & dispatch", <>URL-pattern + SEO-URL parsing, soft 404, automatic action lookup.</>],
["MVC + a second M", <>Controllers, Views (Smarty), Models, plus first-class <strong>Modules</strong> with traits, plugins, interfaces, settings and an observer pattern.</>],
["Multi-database", <>Native MySQL, PDO, PostgreSQL via libpq (<code>psql</code> / <code>postgresql</code>) and ODBC, all behind a unified <code>Db</code> adapter.</>],
["Forms", <>28+ field types built fluently with <code>Form::addInputType…()</code> and a layout helper for divs.</>],
]}
/>
<Callout kind="tip" title="Quick win">
Run <code>nibiru new myapp</code> to scaffold a working project in under 30 seconds.
</Callout>
<h2 id="mmvc">What MMVC actually means</h2>
<p>
The extra <strong>M</strong> stands for <em>Module</em> — a self-contained unit that can declare its own routes, controllers, models, views, and settings. Modules slot into the framework's registry and are wired together at boot.
</p>
</Prose>
<Pagination prev={null} next="Why Nibiru, not Laravel" />
</div>
<RightTOC items={tocItems} activeId="box" />
</div>
</div>
</div>
);
}
// ============================================================
// CANVAS
// ============================================================
function App() {
return (
<DesignCanvas title="Nibiru — Docs Design System" defaultZoom={0.65}>
{/* ============ SECTION: FULL PAGE ============ */}
<DCSection id="full" title="Full page · the system in context">
<DCArtboard id="full-dark" label="Dark — primary" width={1440} height={840}>
<FullPagePreview theme="dark" />
</DCArtboard>
<DCArtboard id="full-light" label="Light — daylight reading" width={1440} height={840}>
<FullPagePreview theme="light" />
</DCArtboard>
</DCSection>
{/* ============ SECTION: TOP NAV ============ */}
<DCSection id="nav" title="Top navigation">
<DCArtboard id="nav-dark" label="Top nav — dark" width={1280} height={120}>
<Frame padded={false}><TopNav theme="dark" /></Frame>
</DCArtboard>
<DCArtboard id="nav-light" label="Top nav — light" width={1280} height={120}>
<Frame theme="light" padded={false}><TopNav theme="light" /></Frame>
</DCArtboard>
</DCSection>
{/* ============ SECTION: SIDEBAR ============ */}
<DCSection id="sidebar" title="Sidebar nav · sectioned with collapsibles">
<DCArtboard id="sb-dark" label="Sidebar — dark" width={320} height={760}>
<Frame padded={false}><Sidebar /></Frame>
</DCArtboard>
<DCArtboard id="sb-light" label="Sidebar — light" width={320} height={760}>
<Frame theme="light" padded={false}><Sidebar /></Frame>
</DCArtboard>
<DCArtboard id="toc" label="On-this-page TOC" width={280} height={520}>
<Frame padded={false}>
<RightTOC
activeId="lifecycle"
items={[
{ id: "overview", level: 1, label: "Overview" },
{ id: "box", level: 1, label: "What's in the box" },
{ id: "mmvc", level: 1, label: "What MMVC means" },
{ id: "module", level: 2, label: "The Module layer" },
{ id: "lifecycle", level: 1, label: "Request lifecycle" },
{ id: "edge", level: 2, label: "Edge cases" },
{ id: "for-whom", level: 1, label: "Who it's for" },
]}
/>
</Frame>
</DCArtboard>
</DCSection>
{/* ============ SECTION: PAGE HEADER & TYPOGRAPHY ============ */}
<DCSection id="prose" title="Page header & body typography">
<DCArtboard id="header" label="Page header" width={780} height={280}>
<Frame>
<PageHeader
crumbs={["The Framework", "Architecture (MMVC)"]}
title="Architecture (MMVC)"
summary="How Model, Module, View, and Controller cross paths through a single dispatcher."
lastUpdated="2 days ago"
/>
</Frame>
</DCArtboard>
<DCArtboard id="prose-dark" label="Prose — dark" width={780} height={620}>
<Frame>
<Prose>
<h2>Headings, body, and rhythm</h2>
<p>
Body text uses <strong>Inter Tight</strong> at 16px with a 1.7 line height the right balance between density and readability for technical content. Inline accents like <em>emphasized terms</em> use the magenta accent color, and <code>inline code</code> sits in a tinted token.
</p>
<h3>Lists carry their own rhythm</h3>
<ul>
<li>List markers pick up the magenta accent.</li>
<li>Spacing between items is tight 6px so dense reference content stays scannable.</li>
<li>Sub-bullets nest cleanly without losing the magenta thread.</li>
</ul>
<blockquote>
"MMVC is the discipline of MVC plus the modularity of plugins — without the indirection of either."
</blockquote>
<p>
Block quotes get a magenta left rail with a subtle gradient pulling the eye toward the quote.
</p>
</Prose>
</Frame>
</DCArtboard>
<DCArtboard id="prose-light" label="Prose — light" width={780} height={620}>
<Frame theme="light">
<Prose>
<h2>Headings, body, and rhythm</h2>
<p>
On the <strong>light theme</strong>, accent colors shift to higher-contrast equivalents purple replaces magenta, teal replaces cyan keeping <em>emphasized terms</em> and <code>inline code</code> legible against the parchment background.
</p>
<h3>Code samples stay dark</h3>
<p>
Code blocks remain on the cosmic plum background even in light mode. The contrast against parchment grounds the page and signals "this is a runnable artifact."
</p>
</Prose>
</Frame>
</DCArtboard>
</DCSection>
{/* ============ SECTION: CODE BLOCKS ============ */}
<DCSection id="code" title="Code blocks · Mission-Control aesthetic">
<DCArtboard id="code-php" label="PHP" width={760} height={440}>
<Frame>
<CodeBlock filename="app/Controllers/BookingController.php" lang="php">
{phpSample}
</CodeBlock>
</Frame>
</DCArtboard>
<DCArtboard id="code-sql" label="SQL" width={760} height={300}>
<Frame>
<CodeBlock filename="queries/recent-bookings.sql" lang="sql">
{sqlSample}
</CodeBlock>
</Frame>
</DCArtboard>
<DCArtboard id="code-yaml" label="YAML config" width={760} height={300}>
<Frame>
<CodeBlock filename="config/app.yaml" lang="yaml">
{yamlSample}
</CodeBlock>
</Frame>
</DCArtboard>
<DCArtboard id="code-tabs" label="Tabbed code (PHP / SQL / YAML)" width={760} height={460}>
<Frame>
<Tabs tabs={[
{ label: "PHP", content: <CodeBlock filename="BookingController.php" lang="php">{phpSample}</CodeBlock> },
{ label: "SQL", content: <CodeBlock filename="queries.sql" lang="sql">{sqlSample}</CodeBlock> },
{ label: "YAML", content: <CodeBlock filename="config/app.yaml" lang="yaml">{yamlSample}</CodeBlock> },
]}/>
</Frame>
</DCArtboard>
</DCSection>
{/* ============ SECTION: CALLOUTS ============ */}
<DCSection id="callouts" title="Callouts · cosmic-coded by severity">
<DCArtboard id="callout-stack" label="All four kinds" width={780} height={460}>
<Frame>
<Callout kind="note" title="Note">
The dispatcher resolves the controller and action automatically you rarely need to wire routes by hand.
</Callout>
<Callout kind="tip" title="Tip">
Use <code>nibiru make:module Blog</code> to scaffold a complete module routes, controller, model, and views.
</Callout>
<Callout kind="warning" title="Backwards-compat break">
In <code>2.0</code>, <code>Form::addInput()</code> is deprecated. Use <code>Form::addInputType()</code> instead.
</Callout>
<Callout kind="danger" title="Don't do this in production">
Never run <code>nibiru migrate --fresh</code> against a production database it drops every table.
</Callout>
</Frame>
</DCArtboard>
</DCSection>
{/* ============ SECTION: TABLES ============ */}
<DCSection id="tables" title="Reference tables">
<DCArtboard id="table-dark" label="Reference table — dark" width={780} height={440}>
<Frame>
<DocTable
headers={["Field type", "Renders", "Validation", "Example"]}
rows={[
["text", "Single-line input", "string", <code>$form->addInputType('text', 'name')</code>],
["email", "Email input + envelope icon", "RFC 5322", <code>$form->addInputType('email', 'address')</code>],
["password", "Masked input + reveal toggle", "min length", <code>$form->addInputType('password', 'pw')</code>],
["select", "Native dropdown", "value-in-set", <code>$form->addInputType('select', 'role')</code>],
["file", "Drag-drop uploader", "mime + size", <code>$form->addInputType('file', 'avatar')</code>],
["date", "Native date picker", "ISO-8601", <code>$form->addInputType('date', 'dob')</code>],
]}
/>
</Frame>
</DCArtboard>
</DCSection>
{/* ============ SECTION: API REFERENCE ============ */}
<DCSection id="api" title="API reference blocks">
<DCArtboard id="api-block" label="Method signature + params + returns" width={780} height={620}>
<Frame>
<ApiBlock
signature={{
name: "Form::addInputType",
args: [
{ name: "type", type: "string" },
{ name: "name", type: "string" },
{ name: "options", type: "array" },
],
returns: "Form",
}}
params={[
{ name: "type", type: "string", required: true, desc: "The input type — one of 28 supported types (text, email, select, file, date, etc.)." },
{ name: "name", type: "string", required: true, desc: "Field name. Becomes the form key and the HTML name attribute." },
{ name: "options", type: "array", default: "[]", desc: "Field-specific options: validation rules, placeholder, default value, attributes." },
]}
returns={{ type: "Form", desc: "The form instance, for fluent chaining." }}
/>
</Frame>
</DCArtboard>
</DCSection>
{/* ============ SECTION: CARDS ============ */}
<DCSection id="cards" title="Feature card grid">
<DCArtboard id="cards-grid" label="Get-started card grid" width={780} height={320}>
<Frame>
<CardGrid cards={[
{
title: "Quick Start",
desc: "Scaffold, run, and ship your first Nibiru app in 60 seconds.",
glow: "linear-gradient(135deg, #b86bff, #ff7ab8)",
icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>,
},
{
title: "Modules",
desc: "The fourth letter in MMVC — first-class plugins with their own scope.",
glow: "linear-gradient(135deg, #6ad9ff, #b86bff)",
icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>,
},
{
title: "Routing",
desc: "URL-pattern + SEO-URL parsing with automatic action lookup.",
glow: "linear-gradient(135deg, #ffb574, #ff7ab8)",
icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2"><circle cx="6" cy="19" r="3"/><circle cx="18" cy="5" r="3"/><path d="M6 16V8a4 4 0 0 1 4-4h4M18 8v8a4 4 0 0 1-4 4h-4"/></svg>,
},
{
title: "Database",
desc: "MySQL, PostgreSQL, ODBC — one unified Db adapter.",
glow: "linear-gradient(135deg, #6ee7b0, #6ad9ff)",
icon: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5M3 12a9 3 0 0 0 18 0"/></svg>,
},
]}/>
</Frame>
</DCArtboard>
</DCSection>
{/* ============ SECTION: SEARCH MODAL ============ */}
<DCSection id="search" title="Search modal · ⌘K">
<DCArtboard id="search-dark" label="Search modal — dark" width={680} height={580}>
<div style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(11,4,16,0.85)", padding: 32 }} className="docs-frame cosmic-bg">
<SearchModal />
</div>
</DCArtboard>
<DCArtboard id="search-light" label="Search modal — light" width={680} height={580}>
<div className="docs-frame theme-light" style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", background: "rgba(250,246,236,0.7)", padding: 32 }}>
<SearchModal />
</div>
</DCArtboard>
</DCSection>
{/* ============ SECTION: PAGINATION + FAB + 404 ============ */}
<DCSection id="utility" title="Pagination, floating help, empty states">
<DCArtboard id="pagination" label="Page navigation footer" width={780} height={160}>
<Frame>
<Pagination prev="Quick Start" next="Project Structure" />
</Frame>
</DCArtboard>
<DCArtboard id="fab" label="Floating help button" width={300} height={240}>
<Frame>
<div style={{ position: "absolute", bottom: 32, right: 32 }}>
<FAB />
</div>
</Frame>
</DCArtboard>
<DCArtboard id="404" label="404 / off-orbit" width={680} height={620}>
<Frame>
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center" }}>
<NotFound />
</div>
</Frame>
</DCArtboard>
</DCSection>
{/* ============ SECTION: MOBILE ============ */}
<DCSection id="mobile" title="Mobile drawer">
<DCArtboard id="drawer-dark" label="Mobile drawer — dark" width={360} height={680}>
<div className="docs-frame" style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", padding: 16, background: "var(--space)" }}>
<MobileDrawer />
</div>
</DCArtboard>
<DCArtboard id="drawer-light" label="Mobile drawer — light" width={360} height={680}>
<div className="docs-frame theme-light" style={{ width: "100%", height: "100%", display: "flex", alignItems: "center", justifyContent: "center", padding: 16, background: "var(--space)" }}>
<MobileDrawer />
</div>
</DCArtboard>
</DCSection>
{/* ============ SECTION: COLOR / TYPE TOKENS ============ */}
<DCSection id="tokens" title="Tokens · color & type">
<DCArtboard id="palette" label="Cosmic palette" width={920} height={360}>
<Frame>
<div style={{ display: "grid", gridTemplateColumns: "repeat(6, 1fr)", gap: 12 }}>
{[
["space", "#0b0410", "Deepest void"],
["space-2", "#120822", "Panel surface"],
["space-3", "#1a0c2e", "Raised surface"],
["plum", "#2a1545", "Nebula plum"],
["star", "#f4eedb", "Primary text"],
["muted", "#8b85a3", "Tertiary text"],
["nebula-mag", "#b86bff", "Accent · links"],
["nebula-cyan", "#6ad9ff", "Accent · note"],
["nebula-amber","#ffb574", "Accent · warn"],
["nebula-rose", "#ff7ab8", "Accent · danger"],
["nebula-green","#6ee7b0", "Accent · tip"],
["nebula-mag-2","#d8a8ff", "Accent · hover"],
].map(([name, hex, desc]) => (
<div key={name} style={{ borderRadius: 12, border: "1px solid var(--line)", overflow: "hidden", background: "var(--space-2)" }}>
<div style={{ height: 80, background: hex, position: "relative", boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.04)" }}/>
<div style={{ padding: "10px 12px" }}>
<div style={{ fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--star)", letterSpacing: "0.04em" }}>--{name}</div>
<div style={{ fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--muted)", marginTop: 2 }}>{hex}</div>
<div style={{ fontSize: 11, color: "var(--star-soft)", marginTop: 4 }}>{desc}</div>
</div>
</div>
))}
</div>
</Frame>
</DCArtboard>
<DCArtboard id="type" label="Type scale" width={780} height={520}>
<Frame>
<div className="docs-frame">
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
{[
["48 · page hero", 48, 600, "Architecture (MMVC)"],
["36 · page title", 36, 600, "What is Nibiru?"],
["28 · h2", 28, 600, "What's in the box"],
["22 · h3", 22, 600, "The Module layer"],
["18 · summary", 18, 400, "A modular MVC PHP framework — MMVC."],
["16 · body", 16, 400, "Body text uses Inter Tight at 16px with 1.7 leading."],
["13 · ui", 13, 500, "Sidebar items, buttons, breadcrumbs"],
["11 · mono / kbd", 11, 500, "GET STARTED · ⌘K · ROUTING & DISPATCH"],
].map(([label, size, weight, sample]) => (
<div key={label} style={{ display: "grid", gridTemplateColumns: "180px 1fr", alignItems: "baseline", gap: 24 }}>
<div style={{ fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--muted)", letterSpacing: "0.06em", textTransform: "uppercase" }}>{label}</div>
<div style={{
fontFamily: typeof label === "string" && label.includes("mono") ? "var(--font-mono)" : "var(--font-sans)",
fontSize: size, fontWeight: weight, color: "var(--star)",
letterSpacing: size >= 28 ? "-0.02em" : "0",
lineHeight: 1.15,
}}>{sample}</div>
</div>
))}
</div>
</div>
</Frame>
</DCArtboard>
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
})();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,291 @@
/* global React */
const { useState: useEState } = React;
// ============================================================
// TABS
// ============================================================
function Tabs({ tabs }) {
const [active, setActive] = useEState(0);
return (
<div className="tabs">
<div className="tabs-list" role="tablist">
{tabs.map((t, i) => (
<button
key={i}
role="tab"
aria-selected={i === active}
className={"tabs-trigger" + (i === active ? " active" : "")}
onClick={() => setActive(i)}
>
{t.icon && <span className="tabs-icon">{t.icon}</span>}
{t.label}
</button>
))}
</div>
<div className="tabs-panel">
{tabs[active].content}
</div>
</div>
);
}
// ============================================================
// API BLOCK (parameter list)
// ============================================================
function ApiBlock({ signature, params = [], returns }) {
return (
<div className="api-block">
<div className="api-signature">
<span className="tk-fn">{signature.name}</span>
<span className="api-paren">(</span>
{signature.args.map((a, i) => (
<React.Fragment key={i}>
<span className="tk-var">{a.name}</span>
<span className="api-colon">: </span>
<span className="tk-cn">{a.type}</span>
{i < signature.args.length - 1 && <span className="api-comma">, </span>}
</React.Fragment>
))}
<span className="api-paren">)</span>
{signature.returns && (
<>
<span className="api-arrow"> </span>
<span className="tk-cn">{signature.returns}</span>
</>
)}
</div>
<div className="api-section-label">Parameters</div>
<ul className="api-params">
{params.map((p, i) => (
<li key={i} className="api-param">
<div className="api-param-head">
<span className="api-param-name">{p.name}</span>
<span className="api-param-type">{p.type}</span>
{p.required && <span className="api-param-required">required</span>}
{p.default !== undefined && <span className="api-param-default">default: <code>{p.default}</code></span>}
</div>
<div className="api-param-desc">{p.desc}</div>
</li>
))}
</ul>
{returns && (
<>
<div className="api-section-label">Returns</div>
<div className="api-returns">
<span className="tk-cn">{returns.type}</span>
<span className="api-returns-desc"> {returns.desc}</span>
</div>
</>
)}
</div>
);
}
// ============================================================
// CARD GRID (feature cards)
// ============================================================
function CardGrid({ cards }) {
return (
<div className="card-grid">
{cards.map((c, i) => (
<a href="#" key={i} className="feature-card">
<div className="feature-card-icon" style={{ background: c.glow }}>
{c.icon}
</div>
<div className="feature-card-title">{c.title}</div>
<div className="feature-card-desc">{c.desc}</div>
<div className="feature-card-arrow">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M7 17 17 7M7 7h10v10"/>
</svg>
</div>
</a>
))}
</div>
);
}
// ============================================================
// SEARCH MODAL
// ============================================================
function SearchModal() {
const recent = ["Routing", "Form::addInputType", "Migrations"];
const results = [
{ section: "The Framework", title: "Routing", excerpt: "URL-pattern + SEO-URL parsing, soft 404, automatic action lookup.", kind: "page" },
{ section: "The Framework", title: "Routing Soft 404", excerpt: "When a route doesn't match, the dispatcher falls through to a soft 404 view.", kind: "section" },
{ section: "Get Started", title: "Quick Start Define a route", excerpt: "Routes are declared in config/routes.php and matched by URL pattern.", kind: "section" },
{ section: "API", title: "Router::register()", excerpt: "Register a new route at runtime. Supports pattern matching with named parameters.", kind: "api" },
];
return (
<div className="search-modal">
<div className="search-input-wrap">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="search-icon">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
</svg>
<input className="search-input" placeholder="Search docs, API, examples..." defaultValue="rout" />
<kbd className="search-kbd">esc</kbd>
</div>
<div className="search-recent">
<div className="search-section-label">Recent</div>
<div className="search-recent-list">
{recent.map((r, i) => (
<button key={i} className="search-chip">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
{r}
</button>
))}
</div>
</div>
<div className="search-results">
<div className="search-section-label">Results</div>
{results.map((r, i) => (
<button key={i} className={"search-result" + (i === 0 ? " active" : "")}>
<div className={"search-result-kind kind-" + r.kind}>{r.kind}</div>
<div className="search-result-body">
<div className="search-result-title">
<span className="search-result-section">{r.section}</span>
<span className="search-result-sep"></span>
<span dangerouslySetInnerHTML={{ __html: r.title.replace(/(rout)/i, "<mark>$1</mark>") }} />
</div>
<div className="search-result-excerpt">{r.excerpt}</div>
</div>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="search-result-go">
<path d="M7 17 17 7M7 7h10v10"/>
</svg>
</button>
))}
</div>
<div className="search-footer">
<span><kbd></kbd> navigate</span>
<span><kbd></kbd> open</span>
<span><kbd>K</kbd> close</span>
<div className="search-credit">Powered by <strong>Nibiru Search</strong></div>
</div>
</div>
);
}
// ============================================================
// FAB (floating help button)
// ============================================================
function FAB() {
return (
<div className="fab-wrap">
<button className="fab" aria-label="Help">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M12 5v14M5 12h14"/>
</svg>
</button>
<div className="fab-tooltip">Ask AI assistant</div>
</div>
);
}
// ============================================================
// PAGINATION (prev / next page)
// ============================================================
function Pagination({ prev, next }) {
return (
<nav className="page-nav">
{prev ? (
<a href="#" className="page-nav-link page-nav-prev">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m15 18-6-6 6-6"/></svg>
<div className="page-nav-stack">
<div className="page-nav-label">Previous</div>
<div className="page-nav-title">{prev}</div>
</div>
</a>
) : <div />}
{next ? (
<a href="#" className="page-nav-link page-nav-next">
<div className="page-nav-stack">
<div className="page-nav-label">Next</div>
<div className="page-nav-title">{next}</div>
</div>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m9 18 6-6-6-6"/></svg>
</a>
) : <div />}
</nav>
);
}
// ============================================================
// MOBILE DRAWER
// ============================================================
function MobileDrawer() {
return (
<div className="mobile-drawer">
<div className="mobile-drawer-header">
<div className="mobile-drawer-brand">
<LotusMark size={26} />
<span>Nibiru</span>
</div>
<button className="mobile-drawer-close" aria-label="Close">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m18 6-12 12M6 6l12 12"/></svg>
</button>
</div>
<button className="mobile-drawer-search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<span>Search</span>
<kbd>K</kbd>
</button>
<Sidebar />
<div className="mobile-drawer-footer">
<button className="mobile-drawer-pill">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41"/></svg>
Theme
</button>
<button className="mobile-drawer-pill">EN</button>
<a href="#" className="mobile-drawer-pill">GitHub </a>
</div>
</div>
);
}
// ============================================================
// 404 / EMPTY STATE
// ============================================================
function NotFound() {
return (
<div className="not-found">
<div className="not-found-orbit">
<svg viewBox="0 0 320 200" width="100%" height="100%" aria-hidden="true">
<defs>
<radialGradient id="lostStar" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#fff4d9"/>
<stop offset="50%" stopColor="#ffb574"/>
<stop offset="100%" stopColor="#b86bff" stopOpacity="0"/>
</radialGradient>
</defs>
{/* dashed orbit */}
<ellipse cx="160" cy="100" rx="140" ry="60" fill="none" stroke="rgba(184,107,255,0.3)" strokeWidth="1" strokeDasharray="3 6"/>
<ellipse cx="160" cy="100" rx="100" ry="42" fill="none" stroke="rgba(184,107,255,0.18)" strokeWidth="1" strokeDasharray="3 6"/>
{/* central star */}
<circle cx="160" cy="100" r="50" fill="url(#lostStar)"/>
<circle cx="160" cy="100" r="6" fill="#fff4d9"/>
{/* drifting probe (off-orbit) */}
<g transform="translate(280 40)">
<circle r="3" fill="#6ad9ff"/>
<circle r="8" fill="#6ad9ff" opacity="0.2"/>
</g>
<text x="290" y="35" fill="#6ad9ff" fontSize="10" fontFamily="JetBrains Mono">PROBE-404</text>
<line x1="160" y1="100" x2="280" y2="40" stroke="#6ad9ff" strokeWidth="1" strokeDasharray="2 3" opacity="0.4"/>
</svg>
</div>
<div className="not-found-code">404 / off-orbit</div>
<h2 className="not-found-title">This page drifted away from Nibiru.</h2>
<p className="not-found-desc">
The probe couldn't find the page you requested. It may have been moved, renamed, or pulled into a different orbit.
</p>
<div className="not-found-actions">
<a href="#" className="btn btn-primary">Return to docs</a>
<a href="#" className="btn btn-ghost">Open search</a>
</div>
</div>
);
}
Object.assign(window, { Tabs, ApiBlock, CardGrid, SearchModal, FAB, Pagination, MobileDrawer, NotFound });

View File

@@ -0,0 +1,192 @@
/* global React */
const { useState } = React;
// ============================================================
// TOP NAV
// ============================================================
function TopNav({ theme = "dark", onToggleTheme = () => {}, locale = "EN" }) {
return (
<header className="topnav">
<div className="topnav-brand">
<LotusMark />
<div className="topnav-brand-text">
<div className="topnav-brand-name">Nibiru</div>
<div className="topnav-brand-tag">Create, Invent, Impress</div>
</div>
</div>
<button className="topnav-search" aria-label="Search docs">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
</svg>
<span>Search</span>
<kbd> K</kbd>
</button>
<div className="topnav-right">
<a href="#" className="topnav-icon" aria-label="GitHub">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 .5C5.65.5.5 5.65.5 12c0 5.08 3.29 9.39 7.86 10.91.58.1.79-.25.79-.56v-1.97c-3.2.69-3.87-1.54-3.87-1.54-.52-1.33-1.27-1.69-1.27-1.69-1.04-.71.08-.7.08-.7 1.15.08 1.76 1.18 1.76 1.18 1.02 1.75 2.68 1.25 3.34.96.1-.74.4-1.25.72-1.54-2.55-.29-5.24-1.28-5.24-5.69 0-1.26.45-2.29 1.18-3.1-.12-.29-.51-1.46.11-3.04 0 0 .96-.31 3.15 1.18a10.95 10.95 0 0 1 5.74 0c2.18-1.49 3.14-1.18 3.14-1.18.62 1.58.23 2.75.11 3.04.74.81 1.18 1.84 1.18 3.1 0 4.42-2.69 5.39-5.26 5.68.41.36.78 1.06.78 2.13v3.16c0 .31.21.67.8.56C20.21 21.39 23.5 17.08 23.5 12 23.5 5.65 18.35.5 12 .5Z"/>
</svg>
</a>
<button className="topnav-icon" aria-label="Toggle theme" onClick={onToggleTheme}>
{theme === "dark" ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/>
</svg>
)}
</button>
<button className="topnav-locale" aria-label="Locale">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20"/>
</svg>
<span>{locale}</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
</div>
</header>
);
}
function LotusMark({ size = 36 }) {
// Real Nibiru logo, sourced from window.NIBIRU_LOTUS so the bundler picks it up
// (it would miss a relative path inside a JSX template string).
const src = (typeof window !== "undefined" && window.NIBIRU_LOTUS) || "docs-system/assets/lotus.png";
return (
<img
src={src}
width={size}
height={Math.round(size * 569 / 861)}
alt="Nibiru"
className="lotus-mark"
style={{ display: "block" }}
/>
);
}
// ============================================================
// SIDEBAR
// ============================================================
function Sidebar() {
const [openSections, setOpenSections] = useState({
"get-started": true, "framework": true, "cli": false, "advanced": false,
});
const toggle = (id) => setOpenSections((s) => ({ ...s, [id]: !s[id] }));
const nav = [
{
id: "get-started", label: "Get Started",
items: [
{ label: "What is Nibiru?", active: true },
{ label: "Why Nibiru, not Laravel" },
{ label: "Installation" },
{ label: "Quick Start" },
{ label: "Project Structure" },
{ label: "Run It Locally" },
{ label: "Deployment" },
],
},
{
id: "framework", label: "The Framework",
items: [
{ label: "Architecture (MMVC)" },
{ label: "Bootstrap & Dispatcher" },
{ label: "Routing" },
{ label: "Controllers" },
{ label: "Views & Smarty" },
{ label: "Models" },
{ label: "Modules", badge: "core" },
{ label: "Forms" },
{ label: "Database & Migrations" },
{ label: "Auth" },
{ label: "Config & Settings" },
{ label: "Pagination" },
{ label: "Registry" },
],
},
{
id: "cli", label: "CLI",
items: [
{ label: "nibiru new" },
{ label: "nibiru migrate" },
{ label: "nibiru make" },
],
},
{
id: "advanced", label: "Advanced",
items: [
{ label: "Plugins" },
{ label: "Observer pattern" },
{ label: "Caching", badge: "new" },
],
},
];
return (
<aside className="sidebar">
<nav>
{nav.map((section) => (
<div key={section.id} className="sidebar-section">
<button
className="sidebar-heading"
onClick={() => toggle(section.id)}
aria-expanded={openSections[section.id]}
>
<span>{section.label}</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
style={{ transform: openSections[section.id] ? "rotate(0deg)" : "rotate(-90deg)", transition: "transform 200ms" }}>
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
{openSections[section.id] && (
<ul className="sidebar-list">
{section.items.map((item) => (
<li key={item.label}>
<a href="#" className={"sidebar-item" + (item.active ? " active" : "")}>
<span>{item.label}</span>
{item.badge && <span className={"sidebar-badge sidebar-badge-" + item.badge}>{item.badge}</span>}
</a>
</li>
))}
</ul>
)}
</div>
))}
</nav>
</aside>
);
}
// ============================================================
// RIGHT TOC ("On this page")
// ============================================================
function RightTOC({ items, activeId }) {
return (
<aside className="toc">
<div className="toc-label">On this page</div>
<ul className="toc-list">
{items.map((item) => (
<li key={item.id} className={"toc-item toc-level-" + item.level}>
<a href={"#" + item.id} className={item.id === activeId ? "active" : ""}>
{item.label}
</a>
</li>
))}
</ul>
<div className="toc-edit">
<a href="#"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z"/></svg> Edit this page</a>
</div>
</aside>
);
}
Object.assign(window, { TopNav, Sidebar, RightTOC, LotusMark });

View File

@@ -0,0 +1,166 @@
/* global React */
const { useState: useTState } = React;
// ============================================================
// PAGE HEADER (breadcrumbs, title, last-updated)
// ============================================================
function PageHeader({ crumbs = [], title, lastUpdated, summary }) {
return (
<header className="page-header">
<nav className="breadcrumbs" aria-label="Breadcrumb">
{crumbs.map((c, i) => (
<React.Fragment key={i}>
<a href="#">{c}</a>
{i < crumbs.length - 1 && <span className="breadcrumb-sep">/</span>}
</React.Fragment>
))}
</nav>
<h1 className="page-title">{title}</h1>
{summary && <p className="page-summary">{summary}</p>}
{lastUpdated && (
<div className="page-meta">
<span className="page-meta-dot" />
Updated {lastUpdated}
</div>
)}
</header>
);
}
// ============================================================
// PROSE — typography sample block
// ============================================================
function Prose({ children }) {
return <div className="prose">{children}</div>;
}
// ============================================================
// CODE BLOCK (Mission Control aesthetic)
// ============================================================
function CodeBlock({ filename, lang = "php", children, lineNumbers = false }) {
const [copied, setCopied] = useTState(false);
const onCopy = () => {
setCopied(true);
setTimeout(() => setCopied(false), 1400);
};
// children is array of {type, text} tokens, or a string
const rendered = Array.isArray(children) ? children : [{ text: children }];
return (
<div className="codeblock">
<div className="codeblock-header">
<div className="codeblock-dots">
<span /><span /><span />
</div>
{filename && <div className="codeblock-filename">{filename}</div>}
<div className="codeblock-lang">{lang}</div>
<button className="codeblock-copy" onClick={onCopy} aria-label="Copy">
{copied ? (
<><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg> Copied</>
) : (
<><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> Copy</>
)}
</button>
</div>
<pre className="codeblock-pre">
<code>
{rendered.map((line, i) => (
<div key={i} className="codeblock-line">
{lineNumbers && <span className="codeblock-ln">{i + 1}</span>}
<span dangerouslySetInnerHTML={{ __html: line.text }} />
</div>
))}
</code>
</pre>
</div>
);
}
// Token helper — wraps text in a syntax-color span
function tk(cls, text) {
return `<span class="tk-${cls}">${text}</span>`;
}
// Pre-tokenized PHP sample (saves us writing a real lexer)
const phpSample = [
{ text: `${tk("kw", "namespace")} ${tk("ns", "App\\\\Controllers")};` },
{ text: `` },
{ text: `${tk("kw", "use")} ${tk("ns", "Nibiru\\\\Controller")};` },
{ text: `${tk("kw", "use")} ${tk("ns", "Nibiru\\\\Form")};` },
{ text: `` },
{ text: `${tk("kw", "class")} ${tk("cn", "BookingController")} ${tk("kw", "extends")} ${tk("cn", "Controller")} {` },
{ text: ` ${tk("kw", "public")} ${tk("kw", "function")} ${tk("fn", "create")}(${tk("var", "$req")}) {` },
{ text: ` ${tk("var", "$form")} = ${tk("kw", "new")} ${tk("cn", "Form")}();` },
{ text: ` ${tk("var", "$form")}->${tk("fn", "addInputType")}(${tk("str", "'email'")}, ${tk("str", "'address'")});` },
{ text: ` ${tk("kw", "return")} ${tk("var", "$this")}->${tk("fn", "view")}(${tk("str", "'booking/create'")}, [` },
{ text: ` ${tk("str", "'form'")} => ${tk("var", "$form")},` },
{ text: ` ]);` },
{ text: ` }` },
{ text: `}` },
];
const sqlSample = [
{ text: `${tk("kw", "SELECT")} ${tk("var", "id")}, ${tk("var", "title")}, ${tk("var", "created_at")}` },
{ text: `${tk("kw", "FROM")} ${tk("cn", "bookings")}` },
{ text: `${tk("kw", "WHERE")} ${tk("var", "user_id")} = ${tk("num", "42")}` },
{ text: ` ${tk("kw", "AND")} ${tk("var", "status")} = ${tk("str", "'confirmed'")}` },
{ text: `${tk("kw", "ORDER BY")} ${tk("var", "created_at")} ${tk("kw", "DESC")}` },
{ text: `${tk("kw", "LIMIT")} ${tk("num", "20")};` },
];
const yamlSample = [
{ text: `${tk("kw", "app")}:` },
{ text: ` ${tk("kw", "name")}: ${tk("str", "Nibiru")}` },
{ text: ` ${tk("kw", "version")}: ${tk("str", "2.0.0")}` },
{ text: ` ${tk("kw", "modules")}:` },
{ text: ` - ${tk("str", "auth")}` },
{ text: ` - ${tk("str", "blog")}` },
{ text: ` - ${tk("str", "api")}` },
];
// ============================================================
// CALLOUTS (cosmic-coded)
// ============================================================
function Callout({ kind = "note", title, children }) {
const icons = {
note: <svg width="16" height="16" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="3" fill="currentColor"/><circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.5" fill="none"/></svg>,
tip: <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 14 8l6 1-4.5 4 1 6-4.5-3-4.5 3 1-6L4 9l6-1z"/></svg>,
warning: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v6M12 16h.01"/></svg>,
danger: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="m12 2 10 18H2L12 2zM12 9v4M12 17h.01"/></svg>,
};
const labels = { note: "Note", tip: "Tip", warning: "Warning", danger: "Danger" };
return (
<div className={"callout callout-" + kind}>
<div className="callout-glyph">{icons[kind]}</div>
<div className="callout-body">
<div className="callout-title">{title || labels[kind]}</div>
<div className="callout-content">{children}</div>
</div>
</div>
);
}
// ============================================================
// TABLE
// ============================================================
function DocTable({ headers, rows }) {
return (
<div className="doc-table-wrap">
<table className="doc-table">
<thead>
<tr>{headers.map((h, i) => <th key={i}>{h}</th>)}</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i}>
{row.map((cell, j) => <td key={j}>{cell}</td>)}
</tr>
))}
</tbody>
</table>
</div>
);
}
Object.assign(window, { PageHeader, Prose, CodeBlock, Callout, DocTable, phpSample, sqlSample, yamlSample, tk });

View File

@@ -0,0 +1,197 @@
/* ============================================================
NIBIRU DOCS — DESIGN TOKENS
Dark primary (cosmic), light variant (parchment)
============================================================ */
:root {
/* ---- Cosmic palette (carried from start page) ---- */
--space: #0b0410; /* deepest void */
--space-2: #120822; /* panel bg */
--space-3: #1a0c2e; /* raised */
--plum: #2a1545; /* deep nebula plum */
--plum-2: #3a1d5e; /* hover plum */
--star: #f4eedb; /* cream — primary text */
--star-soft: #d8d2c0; /* secondary text */
--muted: #8b85a3; /* tertiary / labels */
--muted-2: #5e5878; /* very muted */
--line: rgba(244, 238, 219, 0.08);
--line-strong: rgba(244, 238, 219, 0.16);
--line-glow: rgba(184, 107, 255, 0.25);
/* ---- Accents (the "nebula" colors) ---- */
--nebula-mag: #b86bff; /* magenta — primary accent, links */
--nebula-mag-2: #d8a8ff;
--nebula-cyan: #6ad9ff; /* cyan — secondary accent */
--nebula-amber: #ffb574; /* warm star highlight */
--nebula-rose: #ff7ab8; /* hot pink */
--nebula-green: #6ee7b0; /* aurora green */
/* ---- Semantic (callouts) ---- */
--note-fg: #6ad9ff;
--note-bg: rgba(106, 217, 255, 0.06);
--note-border: rgba(106, 217, 255, 0.30);
--tip-fg: #6ee7b0;
--tip-bg: rgba(110, 231, 176, 0.06);
--tip-border: rgba(110, 231, 176, 0.30);
--warn-fg: #ffb574;
--warn-bg: rgba(255, 181, 116, 0.06);
--warn-border: rgba(255, 181, 116, 0.32);
--danger-fg: #ff7ab8;
--danger-bg: rgba(255, 122, 184, 0.06);
--danger-border:rgba(255, 122, 184, 0.32);
/* ---- Type ---- */
--font-sans: "Inter Tight", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--fs-xs: 12px;
--fs-sm: 13px;
--fs-base: 15px;
--fs-md: 16px;
--fs-lg: 18px;
--fs-xl: 22px;
--fs-2xl: 28px;
--fs-3xl: 36px;
--fs-4xl: 48px;
/* ---- Spacing ---- */
--space-1: 4px;
--space-2x: 8px;
--space-3x: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* ---- Radii ---- */
--r-sm: 4px;
--r-md: 8px;
--r-lg: 12px;
--r-xl: 16px;
--r-2xl: 20px;
--r-pill: 999px;
/* ---- Shadows ---- */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.3);
--shadow-md: 0 8px 24px -8px rgba(0,0,0,0.5);
--shadow-lg: 0 24px 48px -16px rgba(0,0,0,0.6);
--shadow-glow: 0 0 24px rgba(184, 107, 255, 0.25);
/* ---- Layout ---- */
--nav-h: 60px;
--sidebar-w: 280px;
--toc-w: 240px;
--content-max: 760px;
}
/* ---- Light variant (parchment, retained from current docs) ---- */
.theme-light {
--space: #faf6ec;
--space-2: #f3eedc;
--space-3: #ece5cf;
--plum: #ede4ff;
--plum-2: #ddd0f5;
--star: #1a1330;
--star-soft: #4a4360;
--muted: #7a7390;
--muted-2: #a39db5;
--line: rgba(26, 19, 48, 0.08);
--line-strong: rgba(26, 19, 48, 0.16);
--line-glow: rgba(122, 56, 208, 0.30);
--nebula-mag: #7a38d0;
--nebula-mag-2: #9b5fee;
--nebula-cyan: #1f8aaa;
--nebula-amber: #c4731a;
--nebula-rose: #c43e7b;
--nebula-green: #2a8b5e;
--note-fg: #1f6f8a;
--note-bg: rgba(31, 138, 170, 0.08);
--note-border: rgba(31, 138, 170, 0.30);
--tip-fg: #2a8b5e;
--tip-bg: rgba(42, 139, 94, 0.08);
--tip-border: rgba(42, 139, 94, 0.30);
--warn-fg: #c4731a;
--warn-bg: rgba(196, 115, 26, 0.08);
--warn-border: rgba(196, 115, 26, 0.32);
--danger-fg: #c43e7b;
--danger-bg: rgba(196, 62, 123, 0.08);
--danger-border:rgba(196, 62, 123, 0.32);
--shadow-sm: 0 1px 2px rgba(26, 19, 48, 0.08);
--shadow-md: 0 8px 24px -8px rgba(26, 19, 48, 0.12);
--shadow-lg: 0 24px 48px -16px rgba(26, 19, 48, 0.16);
--shadow-glow: 0 0 24px rgba(122, 56, 208, 0.18);
}
/* ---- Base reset for in-frame components ---- */
.docs-frame {
font-family: var(--font-sans);
background: var(--space);
color: var(--star);
font-size: var(--fs-base);
line-height: 1.55;
-webkit-font-smoothing: antialiased;
font-feature-settings: "ss01", "cv11";
}
.docs-frame *,
.docs-frame *::before,
.docs-frame *::after {
box-sizing: border-box;
}
.docs-frame button {
font-family: inherit;
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
}
.docs-frame a {
color: var(--nebula-mag);
text-decoration: none;
text-decoration-color: rgba(184, 107, 255, 0.4);
text-underline-offset: 3px;
}
.docs-frame a:hover {
text-decoration: underline;
}
.docs-frame ::selection {
background: rgba(184, 107, 255, 0.35);
color: var(--star);
}
/* Subtle galaxy texture for atmospheric backgrounds */
.cosmic-bg {
position: relative;
overflow: hidden;
}
.cosmic-bg::before {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(ellipse at 20% 0%, rgba(184, 107, 255, 0.08), transparent 50%),
radial-gradient(ellipse at 80% 100%, rgba(106, 217, 255, 0.05), transparent 50%);
pointer-events: none;
}
.theme-light.cosmic-bg::before,
.theme-light .cosmic-bg::before {
background:
radial-gradient(ellipse at 20% 0%, rgba(122, 56, 208, 0.05), transparent 50%),
radial-gradient(ellipse at 80% 100%, rgba(31, 138, 170, 0.04), transparent 50%);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
/**
* Nibiru Design System — v4.0.0 Tailwind Preset
*
* // tailwind.config.js
* module.exports = {
* presets: [require('@nibiru/design-system/tailwind.preset')],
* content: ['./src/**\/*.{html,js,ts,jsx,tsx,vue,astro}'],
* };
*/
module.exports = {
theme: {
extend: {
colors: {
nibiru: {
void: '#06030f',
space: '#0a0414',
night: '#120825',
plum: '#1c0f3a',
'nebula-mag': '#b86bff',
'nebula-blue': '#5b8dff',
'nebula-amber': '#ffb574',
iris: '#b86bff',
'iris-deep': '#8a3fd0',
'iris-soft': '#d4b4ff',
skyfall: '#5b8dff',
'skyfall-deep': '#3a6ad0',
'skyfall-soft': '#a8c0ff',
aurum: '#ffb574',
star: '#f4eedb',
paper: '#f4eedb',
'paper-2': '#ebe3c8',
mist: '#f8f3e2',
ink: '#0a0414',
'ink-2': '#2a2438',
'ink-deep': '#06030f',
'ink-faint': '#6e6680',
muted: '#6e6680',
success: '#7ad6a3',
rose: '#ff8a9d',
'code-bg': '#050208',
'code-text': '#f4eedb',
},
},
fontFamily: {
display: ['"Inter Tight"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
sans: ['"Inter Tight"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', 'ui-monospace', '"SF Mono"', 'Menlo', 'monospace'],
},
fontSize: {
hero: ['clamp(3.5rem, 7vw + 0.5rem, 7.75rem)', { lineHeight: '0.95', letterSpacing: '-0.04em' }],
section: ['clamp(2.5rem, 4vw + 0.5rem, 5rem)', { lineHeight: '1.02', letterSpacing: '-0.03em' }],
},
letterSpacing: {
display: '-0.04em',
heading: '-0.03em',
body: '-0.005em',
mono: '0.04em',
label: '0.10em',
eyebrow: '0.18em',
},
borderRadius: {
sm: '6px',
md: '10px',
lg: '14px',
xl: '18px',
'2xl':'22px',
pill: '999px',
},
boxShadow: {
'nibiru-sm': '0 1px 2px rgba(0, 0, 0, 0.20)',
'nibiru-md': '0 8px 24px -8px rgba(0, 0, 0, 0.40)',
'nibiru-lg': '0 30px 60px -30px rgba(0, 0, 0, 0.7)',
'nibiru-xl': '0 60px 120px -40px rgba(0, 0, 0, 0.7)',
'nibiru-cosmos': '0 60px 120px -40px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(184, 107, 255, 0.06) inset',
'nibiru-glow-mag': '0 0 24px rgba(184, 107, 255, 0.45)',
'nibiru-glow-amb': '0 0 24px rgba(255, 181, 116, 0.45)',
'nibiru-glow-blue':'0 0 24px rgba(91, 141, 255, 0.45)',
},
backgroundImage: {
'iris-to-sky': 'linear-gradient(135deg, #b86bff 0%, #5b8dff 100%)',
'headline': 'linear-gradient(110deg, #ffb574 0%, #b86bff 50%, #5b8dff 100%)',
'accent': 'linear-gradient(110deg, #ffb574, #b86bff 70%)',
'accent-light': 'linear-gradient(110deg, #b46500, #5b1f9e 70%)',
'nebula': 'radial-gradient(120% 90% at 50% 20%, #2a2156 0%, #1a1442 24%, #0e0a2a 52%, #06050f 82%), linear-gradient(180deg, #06050f 0%, #07060f 100%)',
'mmvc-stage': 'radial-gradient(ellipse at 50% 50%, #1c0f3a, #0a0414 60%)',
},
animation: {
'tele-pulse': 'nibiru-tele-pulse 2.4s ease-in-out infinite',
blink: 'nibiru-blink 1s steps(1) infinite',
breathe: 'nibiru-breathe 18s ease-in-out infinite',
},
keyframes: {
'nibiru-tele-pulse': {
'0%, 100%': { opacity: '0.5', transform: 'scale(0.85)' },
'50%': { opacity: '1', transform: 'scale(1.15)' },
},
'nibiru-blink': {
'50%': { opacity: '0' },
},
'nibiru-breathe': {
'0%, 100%': { transform: 'translateY(0) rotate(0deg)' },
'50%': { transform: 'translateY(-6px) rotate(0.6deg)' },
},
},
},
},
};

View File

@@ -0,0 +1,177 @@
/**
* Nibiru Design System — v4.0.0 "Cosmos"
*
* Canonical source of truth for the brand. Imported globally by the site
* (see astro.config.mjs `customCss`) and published verbatim at
* https://nibiru-framework.com/design-system/tokens.css
*
* v4 is dark-first: the page lives in space, headlines glow magenta-amber,
* the lotus mark is the star at the centre. Light "paper" surfaces are
* available via .alt sections for content-heavy reading.
*
* All tokens namespaced --nibiru-* so they coexist with other systems.
*/
:root {
/* === Cosmos surfaces (dark-first page) === */
--nibiru-void: #06030f; /* deepest, used for chat/code panes */
--nibiru-space: #0a0414; /* the page background */
--nibiru-night: #120825; /* card surface on dark */
--nibiru-plum: #1c0f3a; /* MMVC stage glow base */
/* === Nebula brand colours (the petals, retuned) === */
--nibiru-nebula-mag: #b86bff; /* primary brand — magenta */
--nibiru-nebula-blue: #5b8dff;
--nibiru-nebula-amber:#ffb574;
/* Legacy aliases — same role, mapped to new hexes */
--nibiru-iris: #b86bff;
--nibiru-iris-deep: #8a3fd0;
--nibiru-iris-soft: #d4b4ff;
--nibiru-skyfall: #5b8dff;
--nibiru-skyfall-deep:#3a6ad0;
--nibiru-skyfall-soft:#a8c0ff;
--nibiru-aurum: #ffb574; /* warm amber, brighter than v3 */
--nibiru-aurum-soft: #ffd0a3;
/* === Light "paper" surfaces (for .alt sections) === */
--nibiru-star: #f4eedb; /* the cream — also used as text on dark */
--nibiru-paper: #f4eedb;
--nibiru-paper-2: #ebe3c8;
--nibiru-bg: #f4eedb; /* legacy alias for content cards */
--nibiru-bg-2: #ebe3c8;
--nibiru-mist: #f8f3e2;
--nibiru-lavender: #ece6f3;
--nibiru-lavender-deep:#ddd3eb;
/* === Ink (text on light surfaces) === */
--nibiru-ink: #0a0414; /* deeper than v3, matches space */
--nibiru-ink-2: #2a2438;
--nibiru-ink-deep: #06030f;
--nibiru-ink-soft: #4a4258; /* legacy */
--nibiru-ink-faint: #6e6680;
--nibiru-muted: #6e6680;
/* === Hairlines === */
--nibiru-line: rgba(244, 238, 219, 0.12); /* on dark surfaces */
--nibiru-line-strong: rgba(244, 238, 219, 0.28); /* hover / focus */
--nibiru-line-light: rgba(20, 4, 30, 0.10); /* on light surfaces */
--nibiru-line-2: rgba(20, 4, 30, 0.18);
--nibiru-grid: rgba(244, 238, 219, 0.04); /* faint grid overlay */
/* === Status / accents === */
--nibiru-success: #7ad6a3;
--nibiru-rose: #ff8a9d;
--nibiru-moss: #94a96e;
/* === Code surface === */
--nibiru-code-bg: #050208;
--nibiru-code-line: rgba(244, 238, 219, 0.08);
--nibiru-code-text: #f4eedb;
--nibiru-code-mute: #6e6680;
/* === Twilight (legacy dark theme — kept for Starlight compatibility) === */
--nibiru-dark-bg: #0a0414;
--nibiru-dark-surface: #120825;
--nibiru-dark-surface-raised:#1c0f3a;
--nibiru-dark-ink: #f4eedb;
--nibiru-dark-ink-soft: #c5bfd1;
--nibiru-dark-ink-faint: #6e6680;
/* === Gradients === */
--nibiru-gradient-headline:
linear-gradient(110deg, #ffb574 0%, #b86bff 50%, #5b8dff 100%);
--nibiru-gradient-accent:
linear-gradient(110deg, #ffb574 0%, #b86bff 70%);
--nibiru-gradient-accent-light:
linear-gradient(110deg, #b46500 0%, #5b1f9e 70%);
--nibiru-gradient-iris-to-sky:
linear-gradient(135deg, #b86bff 0%, #5b8dff 100%);
--nibiru-gradient-nebula:
radial-gradient(120% 90% at 50% 20%, #2a2156 0%, #1a1442 24%, #0e0a2a 52%, #06050f 82%),
linear-gradient(180deg, #06050f 0%, #07060f 100%);
--nibiru-gradient-mmvc-stage:
radial-gradient(ellipse at 50% 50%, #1c0f3a, #0a0414 60%);
--nibiru-gradient-lotus-wash:
radial-gradient(ellipse 80% 50% at 0% 0%, rgba(184, 107, 255, 0.10), transparent 60%),
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(91, 141, 255, 0.10), transparent 60%);
/* === Shadows === */
--nibiru-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.20);
--nibiru-shadow-md: 0 8px 24px -8px rgba(0, 0, 0, 0.40);
--nibiru-shadow-lg: 0 30px 60px -30px rgba(0, 0, 0, 0.7);
--nibiru-shadow-xl: 0 60px 120px -40px rgba(0, 0, 0, 0.7);
--nibiru-shadow-cosmos: 0 60px 120px -40px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(184, 107, 255, 0.06) inset;
--nibiru-shadow-glow-mag: 0 0 24px rgba(184, 107, 255, 0.45);
--nibiru-shadow-glow-amb: 0 0 24px rgba(255, 181, 116, 0.45);
--nibiru-shadow-glow-blue:0 0 24px rgba(91, 141, 255, 0.45);
/* === Typography === */
--nibiru-font-text: 'Inter Tight', ui-sans-serif, system-ui, -apple-system, sans-serif;
--nibiru-font-display: 'Inter Tight', ui-sans-serif, system-ui, sans-serif;
--nibiru-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
/* Legacy alias — the v3 components reference --nibiru-font-text */
--nibiru-font-body: var(--nibiru-font-text);
/* Weights */
--nibiru-weight-light: 300;
--nibiru-weight-regular: 400;
--nibiru-weight-medium: 500;
--nibiru-weight-semibold: 600;
--nibiru-weight-bold: 700;
/* Letter-spacing */
--nibiru-tracking-display: -0.04em;
--nibiru-tracking-heading: -0.03em;
--nibiru-tracking-body: -0.005em;
--nibiru-tracking-mono: 0.04em;
--nibiru-tracking-label: 0.10em;
--nibiru-tracking-eyebrow: 0.18em;
/* Type scale */
--nibiru-text-xs: 0.69rem; /* 11px */
--nibiru-text-sm: 0.81rem; /* 13px */
--nibiru-text-md: 0.875rem; /* 14px */
--nibiru-text-base: 1rem; /* 16px — body */
--nibiru-text-lg: 1.125rem; /* 18px */
--nibiru-text-xl: 1.25rem; /* 20px */
--nibiru-text-2xl: 1.5rem;
--nibiru-text-3xl: 2rem;
--nibiru-text-section: clamp(2.5rem, 4vw + 0.5rem, 5rem); /* 40-80 */
--nibiru-text-hero: clamp(3.5rem, 7vw + 0.5rem, 7.75rem); /* 56-124 */
/* === Radii === */
--nibiru-radius-sm: 6px;
--nibiru-radius-md: 10px;
--nibiru-radius-lg: 14px;
--nibiru-radius-xl: 18px;
--nibiru-radius-2xl: 22px;
--nibiru-radius-pill: 999px;
/* === Spacing === */
--nibiru-space-0: 0;
--nibiru-space-1: 0.25rem;
--nibiru-space-2: 0.5rem;
--nibiru-space-3: 0.75rem;
--nibiru-space-4: 1rem;
--nibiru-space-5: 1.5rem;
--nibiru-space-6: 2rem;
--nibiru-space-8: 3rem;
--nibiru-space-10: 4rem;
--nibiru-space-12: 6rem;
--nibiru-space-section: 10rem;
--nibiru-container: 1280px;
--nibiru-content: 50rem;
/* === Motion === */
--nibiru-duration-fast: 160ms;
--nibiru-duration-normal: 240ms;
--nibiru-duration-slow: 500ms;
--nibiru-duration-pulse: 2.4s;
--nibiru-duration-blink: 1s;
--nibiru-duration-breathe: 18s;
--nibiru-ease-out: cubic-bezier(0.2, 0.7, 0.2, 1);
--nibiru-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}

View File

@@ -0,0 +1,153 @@
{
"$schema": "https://design-tokens.github.io/community-group/format/",
"name": "Nibiru Design System",
"version": "4.0.0",
"description": "Cosmos — dark-first AI-framework brand. Magenta-amber-blue nebula on a deep-space body, all-sans (Inter Tight body + display) + JetBrains Mono. Light paper surfaces available via .alt sections for content-heavy reading.",
"color": {
"void": { "$value": "#06030f", "$type": "color", "$description": "Deepest. Used for chat / code panes." },
"space": { "$value": "#0a0414", "$type": "color", "$description": "Page background. The site lives here." },
"night": { "$value": "#120825", "$type": "color", "$description": "Card / panel surface on dark." },
"plum": { "$value": "#1c0f3a", "$type": "color", "$description": "MMVC-stage glow base." },
"nebulaMag": { "$value": "#b86bff", "$type": "color", "$description": "Primary brand — electric magenta. Replaces v3 iris." },
"nebulaBlue": { "$value": "#5b8dff", "$type": "color", "$description": "Cool counterpoint. Replaces v3 skyfall." },
"nebulaAmber": { "$value": "#ffb574", "$type": "color", "$description": "Warm accent. Replaces v3 aurum." },
"iris": { "$value": "#b86bff", "$type": "color" },
"irisDeep": { "$value": "#8a3fd0", "$type": "color" },
"irisSoft": { "$value": "#d4b4ff", "$type": "color" },
"skyfall": { "$value": "#5b8dff", "$type": "color" },
"skyfallDeep": { "$value": "#3a6ad0", "$type": "color" },
"skyfallSoft": { "$value": "#a8c0ff", "$type": "color" },
"aurum": { "$value": "#ffb574", "$type": "color" },
"star": { "$value": "#f4eedb", "$type": "color", "$description": "Body text on dark surfaces. Also used as light paper." },
"paper": { "$value": "#f4eedb", "$type": "color", "$description": "Light surface for .alt sections." },
"paper2": { "$value": "#ebe3c8", "$type": "color" },
"mist": { "$value": "#f8f3e2", "$type": "color" },
"lavender": { "$value": "#ece6f3", "$type": "color" },
"lavenderDeep": { "$value": "#ddd3eb", "$type": "color" },
"ink": { "$value": "#0a0414", "$type": "color", "$description": "Text on light. Same as space — deliberate." },
"ink2": { "$value": "#2a2438", "$type": "color" },
"inkDeep": { "$value": "#06030f", "$type": "color" },
"inkFaint": { "$value": "#6e6680", "$type": "color" },
"muted": { "$value": "#6e6680", "$type": "color" },
"success": { "$value": "#7ad6a3", "$type": "color" },
"rose": { "$value": "#ff8a9d", "$type": "color" },
"moss": { "$value": "#94a96e", "$type": "color" }
},
"line": {
"default": { "$value": "rgba(244, 238, 219, 0.12)", "$type": "color", "$description": "Hairline on dark." },
"strong": { "$value": "rgba(244, 238, 219, 0.28)", "$type": "color", "$description": "Hover / focus on dark." },
"light": { "$value": "rgba(20, 4, 30, 0.10)", "$type": "color", "$description": "Hairline on light." },
"lightStrong": { "$value": "rgba(20, 4, 30, 0.18)", "$type": "color" },
"grid": { "$value": "rgba(244, 238, 219, 0.04)", "$type": "color", "$description": "Faint grid overlay." }
},
"code": {
"bg": { "$value": "#050208", "$type": "color" },
"line": { "$value": "rgba(244, 238, 219, 0.08)", "$type": "color" },
"text": { "$value": "#f4eedb", "$type": "color" },
"mute": { "$value": "#6e6680", "$type": "color" }
},
"gradient": {
"headline": { "$value": "linear-gradient(110deg, #ffb574 0%, #b86bff 50%, #5b8dff 100%)", "$type": "gradient", "$description": "Hero accent <em> — amber→magenta→blue." },
"accent": { "$value": "linear-gradient(110deg, #ffb574, #b86bff 70%)", "$type": "gradient", "$description": "Section title accent on dark." },
"accentLight": { "$value": "linear-gradient(110deg, #b46500, #5b1f9e 70%)", "$type": "gradient", "$description": "Section title accent on light (.alt)." },
"irisToSky": { "$value": "linear-gradient(135deg, #b86bff 0%, #5b8dff 100%)", "$type": "gradient" },
"nebula": { "$value": "radial-gradient(120% 90% at 50% 20%, #2a2156 0%, #1a1442 24%, #0e0a2a 52%, #06050f 82%), linear-gradient(180deg, #06050f 0%, #07060f 100%)", "$type": "gradient" },
"mmvcStage": { "$value": "radial-gradient(ellipse at 50% 50%, #1c0f3a, #0a0414 60%)", "$type": "gradient" },
"lotusWash": { "$value": "radial-gradient(ellipse 80% 50% at 0% 0%, rgba(184,107,255,0.10), transparent 60%), radial-gradient(ellipse 60% 40% at 100% 0%, rgba(91,141,255,0.10), transparent 60%)", "$type": "gradient" }
},
"shadow": {
"sm": { "$value": "0 1px 2px rgba(0, 0, 0, 0.20)", "$type": "shadow" },
"md": { "$value": "0 8px 24px -8px rgba(0, 0, 0, 0.40)", "$type": "shadow" },
"lg": { "$value": "0 30px 60px -30px rgba(0, 0, 0, 0.7)", "$type": "shadow" },
"xl": { "$value": "0 60px 120px -40px rgba(0, 0, 0, 0.7)", "$type": "shadow" },
"cosmos": { "$value": "0 60px 120px -40px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(184, 107, 255, 0.06) inset", "$type": "shadow" },
"glowMag": { "$value": "0 0 24px rgba(184, 107, 255, 0.45)", "$type": "shadow" },
"glowAmber":{ "$value": "0 0 24px rgba(255, 181, 116, 0.45)", "$type": "shadow" },
"glowBlue": { "$value": "0 0 24px rgba(91, 141, 255, 0.45)", "$type": "shadow" }
},
"font": {
"text": { "$value": "'Inter Tight', ui-sans-serif, system-ui, -apple-system, sans-serif", "$type": "fontFamily" },
"display": { "$value": "'Inter Tight', ui-sans-serif, system-ui, sans-serif", "$type": "fontFamily" },
"mono": { "$value": "'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace", "$type": "fontFamily" }
},
"weight": {
"light": { "$value": 300, "$type": "fontWeight" },
"regular": { "$value": 400, "$type": "fontWeight" },
"medium": { "$value": 500, "$type": "fontWeight" },
"semibold": { "$value": 600, "$type": "fontWeight" },
"bold": { "$value": 700, "$type": "fontWeight" }
},
"letterSpacing": {
"display": { "$value": "-0.04em", "$type": "dimension" },
"heading": { "$value": "-0.03em", "$type": "dimension" },
"body": { "$value": "-0.005em", "$type": "dimension" },
"mono": { "$value": "0.04em", "$type": "dimension" },
"label": { "$value": "0.10em", "$type": "dimension" },
"eyebrow": { "$value": "0.18em", "$type": "dimension" }
},
"fontSize": {
"xs": { "$value": "0.69rem", "$type": "dimension" },
"sm": { "$value": "0.81rem", "$type": "dimension" },
"md": { "$value": "0.875rem", "$type": "dimension" },
"base": { "$value": "1rem", "$type": "dimension" },
"lg": { "$value": "1.125rem", "$type": "dimension" },
"xl": { "$value": "1.25rem", "$type": "dimension" },
"2xl": { "$value": "1.5rem", "$type": "dimension" },
"3xl": { "$value": "2rem", "$type": "dimension" },
"section": { "$value": "clamp(2.5rem, 4vw + 0.5rem, 5rem)", "$type": "dimension" },
"hero": { "$value": "clamp(3.5rem, 7vw + 0.5rem, 7.75rem)", "$type": "dimension" }
},
"radius": {
"sm": { "$value": "6px", "$type": "dimension" },
"md": { "$value": "10px", "$type": "dimension" },
"lg": { "$value": "14px", "$type": "dimension" },
"xl": { "$value": "18px", "$type": "dimension" },
"2xl": { "$value": "22px", "$type": "dimension" },
"pill": { "$value": "999px", "$type": "dimension" }
},
"space": {
"0": { "$value": "0", "$type": "dimension" },
"1": { "$value": "0.25rem", "$type": "dimension" },
"2": { "$value": "0.5rem", "$type": "dimension" },
"3": { "$value": "0.75rem", "$type": "dimension" },
"4": { "$value": "1rem", "$type": "dimension" },
"5": { "$value": "1.5rem", "$type": "dimension" },
"6": { "$value": "2rem", "$type": "dimension" },
"8": { "$value": "3rem", "$type": "dimension" },
"10": { "$value": "4rem", "$type": "dimension" },
"12": { "$value": "6rem", "$type": "dimension" },
"section": { "$value": "10rem", "$type": "dimension" },
"container":{ "$value": "1280px", "$type": "dimension" },
"content": { "$value": "50rem", "$type": "dimension" }
},
"motion": {
"fast": { "$value": "160ms", "$type": "duration" },
"normal": { "$value": "240ms", "$type": "duration" },
"slow": { "$value": "500ms", "$type": "duration" },
"pulse": { "$value": "2.4s", "$type": "duration" },
"blink": { "$value": "1s", "$type": "duration" },
"breathe": { "$value": "18s", "$type": "duration" }
},
"ease": {
"out": { "$value": "cubic-bezier(0.2, 0.7, 0.2, 1)", "$type": "cubicBezier" },
"spring": { "$value": "cubic-bezier(0.34, 1.56, 0.64, 1)", "$type": "cubicBezier" }
}
}

View File

@@ -0,0 +1,120 @@
// Nibiru Design System — v4.0.0 "Cosmos" (SCSS)
// === Cosmos surfaces ===
$void: #06030f;
$space: #0a0414;
$night: #120825;
$plum: #1c0f3a;
// === Nebula brand ===
$nebula-mag: #b86bff;
$nebula-blue: #5b8dff;
$nebula-amber: #ffb574;
// Legacy aliases retuned to v4
$iris: #b86bff;
$iris-deep: #8a3fd0;
$iris-soft: #d4b4ff;
$skyfall: #5b8dff;
$skyfall-deep: #3a6ad0;
$skyfall-soft: #a8c0ff;
$aurum: #ffb574;
// === Light surfaces ===
$star: #f4eedb;
$paper: #f4eedb;
$paper-2: #ebe3c8;
$bg: #f4eedb;
$bg-2: #ebe3c8;
$mist: #f8f3e2;
$lavender: #ece6f3;
$lavender-deep:#ddd3eb;
// === Ink ===
$ink: #0a0414;
$ink-2: #2a2438;
$ink-deep: #06030f;
$ink-soft: #4a4258;
$ink-faint: #6e6680;
$muted: #6e6680;
// === Hairlines ===
$line: rgba(244, 238, 219, 0.12);
$line-strong: rgba(244, 238, 219, 0.28);
$line-light: rgba(20, 4, 30, 0.10);
$line-2: rgba(20, 4, 30, 0.18);
$grid: rgba(244, 238, 219, 0.04);
// === Status ===
$success: #7ad6a3;
$rose: #ff8a9d;
$moss: #94a96e;
// === Code surface ===
$code-bg: #050208;
$code-line: rgba(244, 238, 219, 0.08);
$code-text: #f4eedb;
$code-mute: #6e6680;
// === Twilight ===
$dark-bg: #0a0414;
$dark-surface: #120825;
$dark-surface-raised: #1c0f3a;
// === Typography ===
$font-text: ('Inter Tight', ui-sans-serif, system-ui, -apple-system, sans-serif);
$font-display: ('Inter Tight', ui-sans-serif, system-ui, sans-serif);
$font-mono: ('JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace);
$weight: (
light: 300, regular: 400, medium: 500, semibold: 600, bold: 700,
);
$tracking: (
display: -0.04em, heading: -0.03em, body: -0.005em,
mono: 0.04em, label: 0.10em, eyebrow: 0.18em,
);
$text: (
xs: 0.69rem,
sm: 0.81rem,
md: 0.875rem,
base: 1rem,
lg: 1.125rem,
xl: 1.25rem,
2xl: 1.5rem,
3xl: 2rem,
section: clamp(2.5rem, 4vw + 0.5rem, 5rem),
hero: clamp(3.5rem, 7vw + 0.5rem, 7.75rem),
);
// === Radii ===
$radius: (
sm: 6px, md: 10px, lg: 14px, xl: 18px, 2xl: 22px, pill: 999px,
);
// === Spacing ===
$space-scale: (
0: 0, 1: 0.25rem, 2: 0.5rem, 3: 0.75rem, 4: 1rem,
5: 1.5rem, 6: 2rem, 8: 3rem, 10: 4rem, 12: 6rem,
section: 10rem,
);
// === Motion ===
$duration: (
fast: 160ms, normal: 240ms, slow: 500ms,
pulse: 2.4s, blink: 1s, breathe: 18s,
);
$ease: (
out: cubic-bezier(0.2, 0.7, 0.2, 1),
spring: cubic-bezier(0.34, 1.56, 0.64, 1),
);
// === Shadows ===
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.20);
$shadow-md: 0 8px 24px -8px rgba(0, 0, 0, 0.40);
$shadow-lg: 0 30px 60px -30px rgba(0, 0, 0, 0.7);
$shadow-xl: 0 60px 120px -40px rgba(0, 0, 0, 0.7);
$shadow-cosmos: 0 60px 120px -40px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(184, 107, 255, 0.06) inset;
$shadow-glow-mag: 0 0 24px rgba(184, 107, 255, 0.45);
$shadow-glow-amb: 0 0 24px rgba(255, 181, 116, 0.45);
$shadow-glow-blue: 0 0 24px rgba(91, 141, 255, 0.45);

View File

@@ -0,0 +1,38 @@
# =============================================================================
# Local testing override.
#
# Use:
# docker compose -f docker-compose.yml -f docker-compose.local.yml up --build
#
# What this changes vs. production:
# - Publishes the app on http://localhost:4321 (no nginx-proxy needed).
# - Drops the VIRTUAL_HOST / LETSENCRYPT_HOST env vars.
# - Maps the production `nginx-proxy` network reference to the local
# `backend` network — local dev boxes here use `backend` (also external)
# as their proxy network instead of jwilder's default `nginx-proxy` name.
# If you don't have a local proxy at all, comment the networks: blocks
# out and use `network_mode: "host"` (uncomment below) so the container
# can reach Ollama on localhost:11434.
# =============================================================================
services:
docs:
ports:
- "4321:4321"
environment:
VIRTUAL_HOST: ""
VIRTUAL_PORT: ""
LETSENCRYPT_HOST: ""
LETSENCRYPT_EMAIL: ""
# If you run Ollama on the same machine and have no proxy network, uncomment:
# network_mode: "host"
# And in your .env:
# OLLAMA_BASE_URL=http://127.0.0.1:11434
# Compose merges these into the production file's `networks:` block. The
# `name:` override re-points the production reference (`nginx-proxy`) at
# whatever Docker network actually exists locally — here, `backend`.
networks:
nginx-proxy:
name: backend
external: true

71
docs/docker-compose.yml Normal file
View File

@@ -0,0 +1,71 @@
# =============================================================================
# Nibiru docs site — production compose for jwilder/nginx-proxy
#
# Default LLM backend: Ollama at https://api.neuronetz.ai (your own instance).
# No paid API keys required for normal operation.
#
# Prereqs (one-time, on the host):
# docker network create nginx-proxy
#
# Bring up:
# docker compose up -d --build
#
# Update after a docs change:
# git pull && docker compose up -d --build
# =============================================================================
services:
docs:
build:
context: .
dockerfile: Dockerfile
args:
# Used at build time only — to embed docs into the Oracle index.
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-https://api.neuronetz.ai}
OLLAMA_EMBED_MODEL: ${OLLAMA_EMBED_MODEL:-nomic-embed-text}
EMBED_PROVIDER: ${EMBED_PROVIDER:-ollama}
image: nibiru-framework/docs:latest
container_name: nibiru-docs
restart: unless-stopped
expose:
- "4321"
environment:
# --- nginx-proxy (jwilder) routing ---
VIRTUAL_HOST: nibiru-framework.com,www.nibiru-framework.com
VIRTUAL_PORT: "4321"
VIRTUAL_PROTO: "http"
LETSENCRYPT_HOST: nibiru-framework.com,www.nibiru-framework.com
LETSENCRYPT_EMAIL: ${LETSENCRYPT_EMAIL:-stephan.kasdorf@bittomine.com}
# --- Oracle: LLM provider (default = your own Ollama on neuronetz.ai) ---
LLM_PROVIDER: ${LLM_PROVIDER:-ollama}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-https://api.neuronetz.ai}
OLLAMA_CHAT_MODEL: ${OLLAMA_CHAT_MODEL:-qwen2.5-coder:14b}
OLLAMA_EMBED_MODEL: ${OLLAMA_EMBED_MODEL:-nomic-embed-text}
EMBED_PROVIDER: ${EMBED_PROVIDER:-ollama}
# --- Optional fallbacks (only used if LLM_PROVIDER=anthropic / EMBED_PROVIDER=openai) ---
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-claude-haiku-4-5-20251001}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_EMBED_MODEL: ${OPENAI_EMBED_MODEL:-text-embedding-3-small}
ORACLE_TOP_K: ${ORACLE_TOP_K:-6}
ORACLE_MAX_TOKENS: ${ORACLE_MAX_TOKENS:-800}
HOST: 0.0.0.0
PORT: "4321"
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://127.0.0.1:4321/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
networks:
- nginx-proxy
networks:
nginx-proxy:
external: true

10150
docs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
docs/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "docs",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "npm run sync:design-system && astro dev",
"start": "astro dev",
"build": "npm run sync:design-system && npm run build:oracle && astro build",
"build:site": "astro build",
"build:oracle": "node scripts/build-oracle-index.mjs",
"build:corpus": "node scripts/build-corpus.mjs",
"sync:design-system": "node scripts/sync-design-system.mjs",
"translate": "node scripts/translate-docs.mjs",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.95.0",
"@astrojs/node": "^10.1.0",
"@astrojs/sitemap": "^3.7.2",
"@astrojs/starlight": "^0.38.5",
"@vite-pwa/astro": "^1.2.0",
"astro": "^6.2.2",
"openai": "^6.36.0",
"sharp": "^0.34.5",
"workbox-window": "^7.4.1"
}
}

View File

@@ -0,0 +1,61 @@
# Nibiru Atelier — Design System
The visual language of the Nibiru framework. A lotus on cream paper, lit by morning light.
## What's here
| File | Purpose |
|---|---|
| `tokens.json` | Source of truth, [W3C Design Tokens Community Group](https://design-tokens.github.io/community-group/format/) format. |
| `tokens.css` | CSS custom properties, namespaced `--nibiru-*`. |
| `tokens.scss` | SCSS variables and maps. |
| `tailwind.preset.js` | Tailwind preset extending colours, fonts, shadows. |
## Use
### CSS
```html
<link rel="stylesheet"
href="https://nibiru-framework.com/design-system/tokens.css">
<style>
body { background: var(--nibiru-paper); color: var(--nibiru-ink);
font-family: var(--nibiru-font-text); }
h1 { font-variation-settings: var(--nibiru-fv-display-hero);
letter-spacing: var(--nibiru-tracking-display); }
.cta { background: var(--nibiru-ink); color: var(--nibiru-paper);
padding: 0.7rem 1.2rem;
border-radius: var(--nibiru-radius-md); }
</style>
```
### Tailwind
```jsx
<button className="bg-nibiru-ink text-nibiru-paper px-5 py-2 rounded-md font-display tracking-body">
Read the docs
</button>
<h1 className="text-hero text-nibiru-ink tracking-display">
Create. Invent. <em className="text-nibiru-iris-deep">Impress.</em>
</h1>
```
## Full guide
→ <https://nibiru-framework.com/en/design/overview/>
## Versioning
`4.0.0`**Cosmos**. Dark-first AI-framework brand. Magenta-amber-blue nebula
palette on a deep-space body, Space Grotesk display + Inter Tight body +
JetBrains Mono. Light "paper" surfaces still available via `.alt` sections for
content-heavy reading.
`3.0.0`**Atelier × Cosmos**. Reconciles the editorial atelier (paper-and-ink)
with a modern AI-tool dialect: warmer butter page (`#fdf6df`), Geist type,
rounder surfaces (816 px), plus a dark cosmic sub-palette and a code surface
stack for hero / chat / code-card components.
`2.0.0` — Atelier release. Lotus-violet, sky-blue and warm-cream system drawn
from the actual brand mark. Bricolage Grotesque, editorial radii.

View File

@@ -0,0 +1,758 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Documentation page · Nibiru — design mockup</title>
<meta name="description" content="Design mockup for a typical Nibiru documentation page. Layout, type, components and surfaces — built on the v4 Cosmos design system.">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0a0414">
<!-- Type: Inter Tight (body) + Space Grotesk (display) + JetBrains Mono -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@200..800&family=Space+Grotesk:wght@300..700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Canonical Nibiru tokens (also embedded inline below for portability) -->
<link rel="stylesheet" href="./tokens.css">
<style>
/* ============================================================================
* Documentation-page mockup — Nibiru v4 Cosmos
*
* Standalone HTML the designer can open in a browser and riff on. Mirrors
* the production stack (Astro + Starlight) at the visual layer: header on
* top, sidebar nav on the left, article in the middle, on-page TOC on
* the right. Pure CSS, no JS — open it with file:// and it just works.
*
* Hand off: the designer should treat this as the "before" baseline for
* doc pages. The startpage (separate file) sets the brand tone; this
* shows how that tone translates to a content-heavy reading layout.
* ========================================================================= */
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: 'Inter Tight', ui-sans-serif, system-ui, sans-serif;
background: var(--nibiru-space, #0a0414);
background-image:
radial-gradient(ellipse 70% 50% at 0% 0%, rgba(184, 107, 255, 0.10), transparent 60%),
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(91, 141, 255, 0.07), transparent 60%);
background-attachment: fixed;
color: var(--nibiru-star, #f4eedb);
font-size: 16px;
line-height: 1.55;
font-feature-settings: "ss01", "cv11";
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a { color: inherit; text-decoration: none; }
::selection { background: rgba(184, 107, 255, 0.4); color: var(--nibiru-star, #f4eedb); }
.mono, code, kbd, pre {
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
}
.display { font-family: 'Space Grotesk', 'Inter Tight', ui-sans-serif, sans-serif; }
/* ============== HEADER ============== */
.doc-header {
position: sticky; top: 0; z-index: 60;
height: 64px;
display: flex; align-items: center;
padding: 0 24px;
background: rgba(6, 3, 15, 0.72);
border-bottom: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
backdrop-filter: saturate(150%) blur(14px);
-webkit-backdrop-filter: saturate(150%) blur(14px);
}
.doc-header-row {
display: flex; align-items: center; justify-content: space-between;
width: 100%;
gap: 24px;
}
.brand {
display: flex; align-items: center; gap: 10px; flex: none;
font-family: 'Space Grotesk', sans-serif;
}
.brand-mark {
width: 28px; height: 28px;
border-radius: 50%;
background:
radial-gradient(circle at 35% 35%, #f4eedb 0%, #b86bff 40%, #5b8dff 80%, #1c0f3a 100%);
box-shadow: 0 0 12px rgba(184, 107, 255, 0.5);
}
.brand-name {
font-size: 18px; font-weight: 500; letter-spacing: -0.02em;
color: var(--nibiru-star, #f4eedb);
}
.brand-name em { font-style: normal; font-weight: 300; }
.nav-version {
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.04em;
color: var(--nibiru-muted, #6e6680);
padding: 3px 7px;
border: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
border-radius: 999px;
margin-left: 6px;
display: inline-flex; align-items: center; gap: 6px;
}
.nav-version .dot {
width: 5px; height: 5px; border-radius: 50%;
background: #7ad6a3; box-shadow: 0 0 8px rgba(122, 214, 163, 0.7);
}
.doc-nav {
display: flex; gap: 24px; align-items: center;
flex: 1; justify-content: center;
}
.doc-nav a {
font-size: 14px;
color: rgba(244, 238, 219, 0.7);
transition: color 160ms ease;
}
.doc-nav a:hover, .doc-nav a[aria-current="page"] {
color: var(--nibiru-star, #f4eedb);
}
.search-inline {
display: flex; align-items: center; gap: 10px;
min-width: 220px;
height: 36px;
padding: 0 12px;
background: rgba(244, 238, 219, 0.04);
border: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
border-radius: 999px;
color: rgba(244, 238, 219, 0.55);
font-size: 13px;
transition: border-color 160ms ease, background-color 160ms ease;
}
.search-inline:hover { border-color: var(--nibiru-line-strong, rgba(244, 238, 219, 0.28)); }
.search-inline svg { width: 14px; height: 14px; flex: none; }
.search-inline kbd {
margin-left: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--nibiru-muted, #6e6680);
padding: 2px 6px;
border: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
border-radius: 4px;
background: rgba(244, 238, 219, 0.03);
}
.lang-switcher {
display: flex; gap: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
}
.lang-switcher a { padding: 4px 8px; border-radius: 6px; }
.lang-switcher a[aria-current="true"] {
color: var(--nibiru-star, #f4eedb);
background: rgba(184, 107, 255, 0.12);
}
/* ============== LAYOUT SHELL ============== */
.doc-shell {
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 240px;
gap: 0;
max-width: 1400px;
margin: 0 auto;
min-height: calc(100vh - 64px);
}
@media (max-width: 1100px) {
.doc-shell { grid-template-columns: 240px 1fr; }
.doc-toc { display: none; }
}
@media (max-width: 768px) {
.doc-shell { grid-template-columns: 1fr; }
.doc-sidebar { display: none; }
}
/* ============== SIDEBAR ============== */
.doc-sidebar {
position: sticky; top: 64px;
height: calc(100vh - 64px);
overflow-y: auto;
padding: 28px 16px 28px 24px;
border-right: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
background: rgba(10, 4, 20, 0.4);
backdrop-filter: blur(8px);
font-size: 14px;
scrollbar-width: thin;
scrollbar-color: rgba(184, 107, 255, 0.3) transparent;
}
.doc-sidebar h3 {
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
margin: 22px 0 8px;
font-weight: 500;
}
.doc-sidebar h3:first-child { margin-top: 0; }
.doc-sidebar ul {
list-style: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 1px;
}
.doc-sidebar a {
display: block;
padding: 7px 12px;
color: rgba(244, 238, 219, 0.7);
border-left: 1.5px solid transparent;
border-radius: 0 8px 8px 0;
transition: color 160ms ease, border-color 160ms ease, background-color 160ms ease;
}
.doc-sidebar a:hover {
color: var(--nibiru-star, #f4eedb);
border-left-color: var(--nibiru-iris-soft, #d4b4ff);
background: rgba(184, 107, 255, 0.06);
}
.doc-sidebar a[aria-current="page"] {
color: var(--nibiru-star, #f4eedb);
font-weight: 500;
border-left-color: var(--nibiru-nebula-mag, #b86bff);
background: rgba(184, 107, 255, 0.10);
}
/* ============== ARTICLE ============== */
.doc-article {
min-width: 0;
padding: 56px 64px 120px;
max-width: 800px;
margin: 0 auto;
}
@media (max-width: 768px) {
.doc-article { padding: 32px 24px 80px; }
}
.breadcrumbs {
display: flex; align-items: center; gap: 8px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: var(--nibiru-muted, #6e6680);
margin-bottom: 18px;
flex-wrap: wrap;
}
.breadcrumbs a:hover { color: var(--nibiru-star, #f4eedb); }
.breadcrumbs .sep { color: var(--nibiru-line-strong, rgba(244, 238, 219, 0.28)); }
.doc-article h1 {
font-family: 'Space Grotesk', sans-serif;
font-size: clamp(2rem, 1.5rem + 1.6vw, 2.75rem);
line-height: 1.05;
letter-spacing: -0.04em;
font-weight: 400;
margin: 0 0 18px;
color: var(--nibiru-star, #f4eedb);
}
.doc-article h1 em {
font-style: normal; font-weight: 500;
background: linear-gradient(110deg, #ffb574 0%, #b86bff 70%);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
.doc-lede {
font-size: 1.18rem;
line-height: 1.55;
color: rgba(244, 238, 219, 0.72);
max-width: 56ch;
margin: 0 0 36px;
}
.doc-meta {
display: flex; gap: 28px; align-items: center;
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
padding-bottom: 28px;
margin-bottom: 36px;
border-bottom: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
flex-wrap: wrap;
}
.doc-meta strong { color: rgba(244, 238, 219, 0.85); font-weight: 500; }
.doc-meta .pulse {
width: 6px; height: 6px; border-radius: 50%;
background: #7ad6a3;
box-shadow: 0 0 8px rgba(122, 214, 163, 0.7);
}
.doc-article h2 {
font-family: 'Space Grotesk', sans-serif;
font-size: 1.6rem;
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1.15;
margin: 56px 0 16px;
padding-top: 24px;
border-top: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
color: var(--nibiru-star, #f4eedb);
}
.doc-article h3 {
font-family: 'Space Grotesk', sans-serif;
font-size: 1.18rem;
font-weight: 500;
letter-spacing: -0.02em;
margin: 36px 0 12px;
color: var(--nibiru-iris-soft, #d4b4ff);
}
.doc-article h4 {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
margin: 28px 0 10px;
font-weight: 500;
}
.doc-article p {
color: rgba(244, 238, 219, 0.85);
margin: 0 0 18px;
line-height: 1.65;
}
.doc-article strong { color: var(--nibiru-star, #f4eedb); font-weight: 600; }
.doc-article a {
color: var(--nibiru-iris-soft, #d4b4ff);
border-bottom: 1px solid rgba(184, 107, 255, 0.4);
transition: color 200ms ease, border-color 200ms ease;
}
.doc-article a:hover {
color: var(--nibiru-star, #f4eedb);
border-bottom-color: var(--nibiru-nebula-mag, #b86bff);
}
.doc-article ul, .doc-article ol {
margin: 0 0 18px; padding-left: 24px;
}
.doc-article li { margin: 6px 0; color: rgba(244, 238, 219, 0.85); }
/* ============== INLINE CODE ============== */
.doc-article :not(pre) > code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.86em;
background: rgba(184, 107, 255, 0.14);
color: var(--nibiru-iris-soft, #d4b4ff);
padding: 0.05em 0.4em;
border-radius: 6px;
border: 1px solid rgba(184, 107, 255, 0.20);
letter-spacing: 0.04em;
}
/* ============== CODE BLOCK ============== */
.code-block {
background: var(--nibiru-code-bg, #050208);
border: 1px solid var(--nibiru-line-strong, rgba(244, 238, 219, 0.28));
border-radius: 14px;
overflow: hidden;
margin: 22px 0;
box-shadow: 0 30px 60px -30px rgba(0, 0, 0, 0.7);
}
.code-block-head {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 16px;
border-bottom: 1px solid rgba(244, 238, 219, 0.08);
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.10em; text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
}
.code-block-head .file { color: var(--nibiru-nebula-mag, #b86bff); }
.code-block-head .copy {
background: transparent;
border: 1px solid rgba(244, 238, 219, 0.12);
color: rgba(244, 238, 219, 0.7);
padding: 4px 10px;
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.10em;
cursor: pointer;
}
.code-block-head .copy:hover { border-color: var(--nibiru-iris-soft, #d4b4ff); color: var(--nibiru-star, #f4eedb); }
.code-block pre {
margin: 0; padding: 18px 20px;
font-family: 'JetBrains Mono', monospace;
font-size: 13px; line-height: 1.7;
color: var(--nibiru-star, #f4eedb);
overflow-x: auto;
}
.tk-c { color: #6e6680; font-style: italic; }
.tk-k { color: #c8a8ff; }
.tk-cls { color: #f0d4ff; }
.tk-fn { color: #b8d4ff; }
.tk-s { color: #ffd9a3; }
.tk-v { color: #ffb4d8; }
.tk-attr { color: #d4baff; }
.tk-n { color: #b8e0c2; }
/* ============== CALLOUT / ASIDE ============== */
.callout {
background: rgba(184, 107, 255, 0.06);
border: 1px solid rgba(244, 238, 219, 0.10);
border-left: 3px solid var(--nibiru-nebula-mag, #b86bff);
padding: 14px 16px 14px 18px;
border-radius: 0 10px 10px 0;
margin: 22px 0;
}
.callout--tip { border-left-color: var(--nibiru-nebula-amber, #ffb574); background: rgba(255, 181, 116, 0.06); }
.callout--danger { border-left-color: #ff8a9d; background: rgba(255, 138, 157, 0.06); }
.callout-title {
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.10em; text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
margin-bottom: 6px;
font-weight: 500;
}
.callout p { margin: 0; }
/* ============== TABLE ============== */
.doc-article table {
width: 100%;
border-collapse: collapse;
margin: 22px 0;
font-size: 14px;
}
.doc-article thead {
border-bottom: 2px solid var(--nibiru-line-strong, rgba(244, 238, 219, 0.28));
}
.doc-article th {
text-align: left;
padding: 10px 12px 10px 0;
font-family: 'JetBrains Mono', monospace;
font-weight: 500;
font-size: 11px;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
}
.doc-article td {
padding: 10px 12px 10px 0;
border-bottom: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
color: rgba(244, 238, 219, 0.85);
vertical-align: top;
}
/* ============== ON-PAGE TOC (right rail) ============== */
.doc-toc {
position: sticky; top: 64px;
height: calc(100vh - 64px);
overflow-y: auto;
padding: 56px 24px 56px 16px;
border-left: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
font-size: 13px;
}
.doc-toc h4 {
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
margin: 0 0 12px;
font-weight: 500;
}
.doc-toc ul {
list-style: none; padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 4px;
}
.doc-toc a {
color: rgba(244, 238, 219, 0.6);
display: block;
padding: 4px 0;
transition: color 160ms ease;
line-height: 1.4;
}
.doc-toc a:hover { color: var(--nibiru-star, #f4eedb); }
.doc-toc a.active {
color: var(--nibiru-star, #f4eedb);
border-left: 2px solid var(--nibiru-nebula-mag, #b86bff);
margin-left: -10px;
padding-left: 8px;
}
.doc-toc .l2 { padding-left: 12px; font-size: 12px; }
/* ============== PAGE FOOTER (prev / next) ============== */
.page-pagination {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 80px;
padding-top: 36px;
border-top: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
}
@media (max-width: 600px) {
.page-pagination { grid-template-columns: 1fr; }
}
.page-pagination a {
background: var(--nibiru-night, #120825);
border: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
border-radius: 14px;
padding: 16px 18px;
transition: border-color 200ms, transform 200ms;
}
.page-pagination a:hover {
border-color: var(--nibiru-nebula-mag, #b86bff);
transform: translateY(-1px);
}
.page-pagination .lbl {
font-family: 'JetBrains Mono', monospace;
font-size: 11px; letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--nibiru-muted, #6e6680);
display: block;
margin-bottom: 4px;
}
.page-pagination .ttl {
font-size: 15px; font-weight: 500;
color: var(--nibiru-star, #f4eedb);
}
.page-pagination .next { text-align: right; }
/* ============== HELP STRIP ============== */
.help-strip {
margin-top: 48px;
padding: 18px 22px;
background: rgba(244, 238, 219, 0.03);
border: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
border-radius: 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
font-size: 14px;
}
.help-strip-text { color: rgba(244, 238, 219, 0.75); }
.help-strip-actions { display: flex; gap: 8px; }
.help-strip-actions a {
padding: 7px 14px;
border: 1px solid var(--nibiru-line-strong, rgba(244, 238, 219, 0.28));
border-radius: 999px;
font-size: 13px;
color: rgba(244, 238, 219, 0.85);
transition: color 160ms, border-color 160ms;
border-bottom: 1px solid var(--nibiru-line-strong, rgba(244, 238, 219, 0.28));
}
.help-strip-actions a:hover {
color: var(--nibiru-star, #f4eedb);
border-color: var(--nibiru-iris-soft, #d4b4ff);
}
</style>
</head>
<body>
<!-- ====================== HEADER ====================== -->
<header class="doc-header">
<div class="doc-header-row">
<a class="brand" href="#">
<span class="brand-mark"></span>
<span class="brand-name">Nibiru<em> docs</em></span>
<span class="nav-version"><span class="dot"></span>v0.9.2</span>
</a>
<nav class="doc-nav" aria-label="Primary">
<a href="#" aria-current="page">Docs</a>
<a href="#">MMVC</a>
<a href="#">AI module</a>
<a href="#">CLI</a>
<a href="#">Showcase</a>
</nav>
<div class="doc-header-tools" style="display: flex; align-items: center; gap: 12px;">
<div class="search-inline" role="search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-3-3"/></svg>
<span>Search the docs</span>
<kbd>⌘ K</kbd>
</div>
<div class="lang-switcher" aria-label="Language">
<a href="#" aria-current="true">EN</a>
<a href="#">DE</a>
<a href="#">JA</a>
<a href="#">ES</a>
<a href="#">FR</a>
</div>
</div>
</div>
</header>
<!-- ====================== SHELL ====================== -->
<div class="doc-shell">
<!-- ====== SIDEBAR ====== -->
<aside class="doc-sidebar" aria-label="Section navigation">
<h3>Get started</h3>
<ul>
<li><a href="#">What is Nibiru?</a></li>
<li><a href="#">Installation</a></li>
<li><a href="#">Quick start</a></li>
<li><a href="#" aria-current="page">Project structure</a></li>
<li><a href="#">Run it locally</a></li>
<li><a href="#">Deployment</a></li>
</ul>
<h3>The framework</h3>
<ul>
<li><a href="#">Architecture (MMVC)</a></li>
<li><a href="#">Bootstrap &amp; Dispatcher</a></li>
<li><a href="#">Routing</a></li>
<li><a href="#">Controllers</a></li>
<li><a href="#">Views &amp; Smarty</a></li>
<li><a href="#">Models</a></li>
<li><a href="#">Modules</a></li>
<li><a href="#">Forms</a></li>
<li><a href="#">Database &amp; Migrations</a></li>
<li><a href="#">Auth</a></li>
</ul>
<h3>AI in Nibiru</h3>
<ul>
<li><a href="#">The AI module</a></li>
<li><a href="#">Chat plugin</a></li>
<li><a href="#">Embed plugin</a></li>
<li><a href="#">RAG plugin</a></li>
<li><a href="#">Agent plugin</a></li>
<li><a href="#">Ask the Oracle</a></li>
</ul>
</aside>
<!-- ====== ARTICLE ====== -->
<main class="doc-article">
<nav class="breadcrumbs" aria-label="Breadcrumb">
<a href="#">Docs</a>
<span class="sep">/</span>
<a href="#">Get started</a>
<span class="sep">/</span>
<span>Project structure</span>
</nav>
<h1>Project <em>structure</em></h1>
<p class="doc-lede">Where Nibiru puts things, and why. The shape on disk maps to the four MMVC roles — read top-down and the framework will explain itself.</p>
<div class="doc-meta">
<span><span class="pulse"></span> <strong>Stable</strong> · v0.9.2</span>
<span>Updated <strong>2 days ago</strong></span>
<span>Reading time <strong>~ 6 min</strong></span>
<span>Edit on <strong>GitHub</strong></span>
</div>
<p>A fresh Nibiru install is just a Composer-managed PHP project with a single CLI binary, an <code>application/</code> tree for your code, and a <code>core/</code> tree for the framework itself. Everything else — Smarty's compiled templates, the public docroot, the migrations history — falls out of those four conventions.</p>
<h2 id="overview">Overview</h2>
<p>Open the repo and you'll see this:</p>
<div class="code-block">
<div class="code-block-head">
<span class="file">tree.txt</span>
<button class="copy" type="button">Copy</button>
</div>
<pre><span class="tk-c"># A Nibiru project, top level</span>
my-app/
├── application/ <span class="tk-c">// your code lives here</span>
│ ├── controller/
│ ├── module/
│ ├── view/
│ └── settings/
├── core/ <span class="tk-c">// the framework — don't edit</span>
├── public/ <span class="tk-c">// docroot — point your vhost here</span>
├── nibiru <span class="tk-c">// the CLI binary</span>
├── composer.json
└── index.php <span class="tk-c">// front controller</span></pre>
</div>
<h3 id="application">application/</h3>
<p>Your code. Each role in <strong>MMVC</strong> is its own subdirectory: <code>controller/</code>, <code>module/</code>, <code>view/</code>. The fourth — Models — live inside their own <code>module/&lt;name&gt;/models/</code> subtrees, because in MMVC every module owns the data shape it touches.</p>
<div class="callout callout--tip">
<div class="callout-title">Tip</div>
<p>Run <code>./nibiru -c products</code> and the CLI will scaffold the controller, the module, the navigation entry and a Smarty template into the right places automatically.</p>
</div>
<h3 id="core">core/</h3>
<p>The framework itself. <strong>Don't edit it.</strong> If you find yourself reaching in here, the answer is almost always to extend a class in <code>application/</code> instead. The single exception: <a href="#">contributing back</a>.</p>
<h3 id="public">public/</h3>
<p>Your vhost docroot. Static assets are served directly from this directory; everything else falls through to the front controller via <code>index.php</code>. The <code>.htaccess</code> in the project root takes care of rewrites for Apache; the included <a href="#"><code>vhost.conf</code></a> shows the equivalent for nginx.</p>
<h2 id="naming">Naming conventions</h2>
<p>Every controller is <code>&lt;name&gt;Controller.php</code>, every module sits at <code>application/module/&lt;name&gt;/</code>, every Smarty template lives at <code>application/view/templates/&lt;name&gt;.tpl</code>. The CLI enforces this — you don't have to remember it.</p>
<table>
<thead>
<tr>
<th>Path</th>
<th>Created by</th>
<th>Owns</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>application/controller/</code></td>
<td><code>./nibiru -c &lt;name&gt;</code></td>
<td>Routing, dispatch, lifecycle</td>
</tr>
<tr>
<td><code>application/module/</code></td>
<td><code>./nibiru -m &lt;name&gt;</code></td>
<td>Capabilities, plugins, models</td>
</tr>
<tr>
<td><code>application/view/templates/</code></td>
<td>scaffolded with controller</td>
<td>Smarty 3 templates</td>
</tr>
<tr>
<td><code>application/settings/migrations/</code></td>
<td><code>./nibiru -mi-add &lt;n&gt;</code></td>
<td>Timestamped SQL migrations</td>
</tr>
</tbody>
</table>
<div class="callout">
<div class="callout-title">Note</div>
<p>Smarty templates compile to <code>application/view/templates_c/</code> at first request. That directory is gitignored — clear it with <code>./nibiru -cache-clear</code> if a template ever sticks.</p>
</div>
<h2 id="next">Next</h2>
<p>You know where things go. Now wire one up — the <a href="#">Quick start</a> takes you from an empty project to a running controller in five commands.</p>
<!-- prev / next -->
<nav class="page-pagination" aria-label="Page navigation">
<a class="prev" href="#">
<span class="lbl">← Previous</span>
<span class="ttl">Quick start</span>
</a>
<a class="next" href="#">
<span class="lbl">Next →</span>
<span class="ttl">Run it locally</span>
</a>
</nav>
<!-- help strip -->
<aside class="help-strip">
<span class="help-strip-text">Was this page helpful?</span>
<div class="help-strip-actions">
<a href="#">Yes</a>
<a href="#">Suggest an edit</a>
</div>
</aside>
</main>
<!-- ====== ON-PAGE TOC ====== -->
<aside class="doc-toc" aria-label="On this page">
<h4>On this page</h4>
<ul>
<li><a href="#overview" class="active">Overview</a></li>
<li><a href="#application" class="l2">application/</a></li>
<li><a href="#core" class="l2">core/</a></li>
<li><a href="#public" class="l2">public/</a></li>
<li><a href="#naming">Naming conventions</a></li>
<li><a href="#next">Next</a></li>
</ul>
<h4 style="margin-top: 36px;">Notes</h4>
<p style="font-size: 12px; color: var(--nibiru-muted, #6e6680); line-height: 1.5;">
This is a design mockup, not a real docs page. The TOC scroll-spy, search modal,
and language-switcher are wired-up in the production Astro/Starlight build —
treat the visual elements here as the brief.
</p>
</aside>
</div>
</body>
</html>

View File

@@ -0,0 +1,108 @@
/**
* Nibiru Design System — v4.0.0 Tailwind Preset
*
* // tailwind.config.js
* module.exports = {
* presets: [require('@nibiru/design-system/tailwind.preset')],
* content: ['./src/**\/*.{html,js,ts,jsx,tsx,vue,astro}'],
* };
*/
module.exports = {
theme: {
extend: {
colors: {
nibiru: {
void: '#06030f',
space: '#0a0414',
night: '#120825',
plum: '#1c0f3a',
'nebula-mag': '#b86bff',
'nebula-blue': '#5b8dff',
'nebula-amber': '#ffb574',
iris: '#b86bff',
'iris-deep': '#8a3fd0',
'iris-soft': '#d4b4ff',
skyfall: '#5b8dff',
'skyfall-deep': '#3a6ad0',
'skyfall-soft': '#a8c0ff',
aurum: '#ffb574',
star: '#f4eedb',
paper: '#f4eedb',
'paper-2': '#ebe3c8',
mist: '#f8f3e2',
ink: '#0a0414',
'ink-2': '#2a2438',
'ink-deep': '#06030f',
'ink-faint': '#6e6680',
muted: '#6e6680',
success: '#7ad6a3',
rose: '#ff8a9d',
'code-bg': '#050208',
'code-text': '#f4eedb',
},
},
fontFamily: {
display: ['"Inter Tight"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
sans: ['"Inter Tight"', 'ui-sans-serif', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', 'ui-monospace', '"SF Mono"', 'Menlo', 'monospace'],
},
fontSize: {
hero: ['clamp(3.5rem, 7vw + 0.5rem, 7.75rem)', { lineHeight: '0.95', letterSpacing: '-0.04em' }],
section: ['clamp(2.5rem, 4vw + 0.5rem, 5rem)', { lineHeight: '1.02', letterSpacing: '-0.03em' }],
},
letterSpacing: {
display: '-0.04em',
heading: '-0.03em',
body: '-0.005em',
mono: '0.04em',
label: '0.10em',
eyebrow: '0.18em',
},
borderRadius: {
sm: '6px',
md: '10px',
lg: '14px',
xl: '18px',
'2xl':'22px',
pill: '999px',
},
boxShadow: {
'nibiru-sm': '0 1px 2px rgba(0, 0, 0, 0.20)',
'nibiru-md': '0 8px 24px -8px rgba(0, 0, 0, 0.40)',
'nibiru-lg': '0 30px 60px -30px rgba(0, 0, 0, 0.7)',
'nibiru-xl': '0 60px 120px -40px rgba(0, 0, 0, 0.7)',
'nibiru-cosmos': '0 60px 120px -40px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(184, 107, 255, 0.06) inset',
'nibiru-glow-mag': '0 0 24px rgba(184, 107, 255, 0.45)',
'nibiru-glow-amb': '0 0 24px rgba(255, 181, 116, 0.45)',
'nibiru-glow-blue':'0 0 24px rgba(91, 141, 255, 0.45)',
},
backgroundImage: {
'iris-to-sky': 'linear-gradient(135deg, #b86bff 0%, #5b8dff 100%)',
'headline': 'linear-gradient(110deg, #ffb574 0%, #b86bff 50%, #5b8dff 100%)',
'accent': 'linear-gradient(110deg, #ffb574, #b86bff 70%)',
'accent-light': 'linear-gradient(110deg, #b46500, #5b1f9e 70%)',
'nebula': 'radial-gradient(120% 90% at 50% 20%, #2a2156 0%, #1a1442 24%, #0e0a2a 52%, #06050f 82%), linear-gradient(180deg, #06050f 0%, #07060f 100%)',
'mmvc-stage': 'radial-gradient(ellipse at 50% 50%, #1c0f3a, #0a0414 60%)',
},
animation: {
'tele-pulse': 'nibiru-tele-pulse 2.4s ease-in-out infinite',
blink: 'nibiru-blink 1s steps(1) infinite',
breathe: 'nibiru-breathe 18s ease-in-out infinite',
},
keyframes: {
'nibiru-tele-pulse': {
'0%, 100%': { opacity: '0.5', transform: 'scale(0.85)' },
'50%': { opacity: '1', transform: 'scale(1.15)' },
},
'nibiru-blink': {
'50%': { opacity: '0' },
},
'nibiru-breathe': {
'0%, 100%': { transform: 'translateY(0) rotate(0deg)' },
'50%': { transform: 'translateY(-6px) rotate(0.6deg)' },
},
},
},
},
};

View File

@@ -0,0 +1,177 @@
/**
* Nibiru Design System — v4.0.0 "Cosmos"
*
* Canonical source of truth for the brand. Imported globally by the site
* (see astro.config.mjs `customCss`) and published verbatim at
* https://nibiru-framework.com/design-system/tokens.css
*
* v4 is dark-first: the page lives in space, headlines glow magenta-amber,
* the lotus mark is the star at the centre. Light "paper" surfaces are
* available via .alt sections for content-heavy reading.
*
* All tokens namespaced --nibiru-* so they coexist with other systems.
*/
:root {
/* === Cosmos surfaces (dark-first page) === */
--nibiru-void: #06030f; /* deepest, used for chat/code panes */
--nibiru-space: #0a0414; /* the page background */
--nibiru-night: #120825; /* card surface on dark */
--nibiru-plum: #1c0f3a; /* MMVC stage glow base */
/* === Nebula brand colours (the petals, retuned) === */
--nibiru-nebula-mag: #b86bff; /* primary brand — magenta */
--nibiru-nebula-blue: #5b8dff;
--nibiru-nebula-amber:#ffb574;
/* Legacy aliases — same role, mapped to new hexes */
--nibiru-iris: #b86bff;
--nibiru-iris-deep: #8a3fd0;
--nibiru-iris-soft: #d4b4ff;
--nibiru-skyfall: #5b8dff;
--nibiru-skyfall-deep:#3a6ad0;
--nibiru-skyfall-soft:#a8c0ff;
--nibiru-aurum: #ffb574; /* warm amber, brighter than v3 */
--nibiru-aurum-soft: #ffd0a3;
/* === Light "paper" surfaces (for .alt sections) === */
--nibiru-star: #f4eedb; /* the cream — also used as text on dark */
--nibiru-paper: #f4eedb;
--nibiru-paper-2: #ebe3c8;
--nibiru-bg: #f4eedb; /* legacy alias for content cards */
--nibiru-bg-2: #ebe3c8;
--nibiru-mist: #f8f3e2;
--nibiru-lavender: #ece6f3;
--nibiru-lavender-deep:#ddd3eb;
/* === Ink (text on light surfaces) === */
--nibiru-ink: #0a0414; /* deeper than v3, matches space */
--nibiru-ink-2: #2a2438;
--nibiru-ink-deep: #06030f;
--nibiru-ink-soft: #4a4258; /* legacy */
--nibiru-ink-faint: #6e6680;
--nibiru-muted: #6e6680;
/* === Hairlines === */
--nibiru-line: rgba(244, 238, 219, 0.12); /* on dark surfaces */
--nibiru-line-strong: rgba(244, 238, 219, 0.28); /* hover / focus */
--nibiru-line-light: rgba(20, 4, 30, 0.10); /* on light surfaces */
--nibiru-line-2: rgba(20, 4, 30, 0.18);
--nibiru-grid: rgba(244, 238, 219, 0.04); /* faint grid overlay */
/* === Status / accents === */
--nibiru-success: #7ad6a3;
--nibiru-rose: #ff8a9d;
--nibiru-moss: #94a96e;
/* === Code surface === */
--nibiru-code-bg: #050208;
--nibiru-code-line: rgba(244, 238, 219, 0.08);
--nibiru-code-text: #f4eedb;
--nibiru-code-mute: #6e6680;
/* === Twilight (legacy dark theme — kept for Starlight compatibility) === */
--nibiru-dark-bg: #0a0414;
--nibiru-dark-surface: #120825;
--nibiru-dark-surface-raised:#1c0f3a;
--nibiru-dark-ink: #f4eedb;
--nibiru-dark-ink-soft: #c5bfd1;
--nibiru-dark-ink-faint: #6e6680;
/* === Gradients === */
--nibiru-gradient-headline:
linear-gradient(110deg, #ffb574 0%, #b86bff 50%, #5b8dff 100%);
--nibiru-gradient-accent:
linear-gradient(110deg, #ffb574 0%, #b86bff 70%);
--nibiru-gradient-accent-light:
linear-gradient(110deg, #b46500 0%, #5b1f9e 70%);
--nibiru-gradient-iris-to-sky:
linear-gradient(135deg, #b86bff 0%, #5b8dff 100%);
--nibiru-gradient-nebula:
radial-gradient(120% 90% at 50% 20%, #2a2156 0%, #1a1442 24%, #0e0a2a 52%, #06050f 82%),
linear-gradient(180deg, #06050f 0%, #07060f 100%);
--nibiru-gradient-mmvc-stage:
radial-gradient(ellipse at 50% 50%, #1c0f3a, #0a0414 60%);
--nibiru-gradient-lotus-wash:
radial-gradient(ellipse 80% 50% at 0% 0%, rgba(184, 107, 255, 0.10), transparent 60%),
radial-gradient(ellipse 60% 40% at 100% 0%, rgba(91, 141, 255, 0.10), transparent 60%);
/* === Shadows === */
--nibiru-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.20);
--nibiru-shadow-md: 0 8px 24px -8px rgba(0, 0, 0, 0.40);
--nibiru-shadow-lg: 0 30px 60px -30px rgba(0, 0, 0, 0.7);
--nibiru-shadow-xl: 0 60px 120px -40px rgba(0, 0, 0, 0.7);
--nibiru-shadow-cosmos: 0 60px 120px -40px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(184, 107, 255, 0.06) inset;
--nibiru-shadow-glow-mag: 0 0 24px rgba(184, 107, 255, 0.45);
--nibiru-shadow-glow-amb: 0 0 24px rgba(255, 181, 116, 0.45);
--nibiru-shadow-glow-blue:0 0 24px rgba(91, 141, 255, 0.45);
/* === Typography === */
--nibiru-font-text: 'Inter Tight', ui-sans-serif, system-ui, -apple-system, sans-serif;
--nibiru-font-display: 'Inter Tight', ui-sans-serif, system-ui, sans-serif;
--nibiru-font-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
/* Legacy alias — the v3 components reference --nibiru-font-text */
--nibiru-font-body: var(--nibiru-font-text);
/* Weights */
--nibiru-weight-light: 300;
--nibiru-weight-regular: 400;
--nibiru-weight-medium: 500;
--nibiru-weight-semibold: 600;
--nibiru-weight-bold: 700;
/* Letter-spacing */
--nibiru-tracking-display: -0.04em;
--nibiru-tracking-heading: -0.03em;
--nibiru-tracking-body: -0.005em;
--nibiru-tracking-mono: 0.04em;
--nibiru-tracking-label: 0.10em;
--nibiru-tracking-eyebrow: 0.18em;
/* Type scale */
--nibiru-text-xs: 0.69rem; /* 11px */
--nibiru-text-sm: 0.81rem; /* 13px */
--nibiru-text-md: 0.875rem; /* 14px */
--nibiru-text-base: 1rem; /* 16px — body */
--nibiru-text-lg: 1.125rem; /* 18px */
--nibiru-text-xl: 1.25rem; /* 20px */
--nibiru-text-2xl: 1.5rem;
--nibiru-text-3xl: 2rem;
--nibiru-text-section: clamp(2.5rem, 4vw + 0.5rem, 5rem); /* 40-80 */
--nibiru-text-hero: clamp(3.5rem, 7vw + 0.5rem, 7.75rem); /* 56-124 */
/* === Radii === */
--nibiru-radius-sm: 6px;
--nibiru-radius-md: 10px;
--nibiru-radius-lg: 14px;
--nibiru-radius-xl: 18px;
--nibiru-radius-2xl: 22px;
--nibiru-radius-pill: 999px;
/* === Spacing === */
--nibiru-space-0: 0;
--nibiru-space-1: 0.25rem;
--nibiru-space-2: 0.5rem;
--nibiru-space-3: 0.75rem;
--nibiru-space-4: 1rem;
--nibiru-space-5: 1.5rem;
--nibiru-space-6: 2rem;
--nibiru-space-8: 3rem;
--nibiru-space-10: 4rem;
--nibiru-space-12: 6rem;
--nibiru-space-section: 10rem;
--nibiru-container: 1280px;
--nibiru-content: 50rem;
/* === Motion === */
--nibiru-duration-fast: 160ms;
--nibiru-duration-normal: 240ms;
--nibiru-duration-slow: 500ms;
--nibiru-duration-pulse: 2.4s;
--nibiru-duration-blink: 1s;
--nibiru-duration-breathe: 18s;
--nibiru-ease-out: cubic-bezier(0.2, 0.7, 0.2, 1);
--nibiru-ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}

View File

@@ -0,0 +1,153 @@
{
"$schema": "https://design-tokens.github.io/community-group/format/",
"name": "Nibiru Design System",
"version": "4.0.0",
"description": "Cosmos — dark-first AI-framework brand. Magenta-amber-blue nebula on a deep-space body, all-sans (Inter Tight body + display) + JetBrains Mono. Light paper surfaces available via .alt sections for content-heavy reading.",
"color": {
"void": { "$value": "#06030f", "$type": "color", "$description": "Deepest. Used for chat / code panes." },
"space": { "$value": "#0a0414", "$type": "color", "$description": "Page background. The site lives here." },
"night": { "$value": "#120825", "$type": "color", "$description": "Card / panel surface on dark." },
"plum": { "$value": "#1c0f3a", "$type": "color", "$description": "MMVC-stage glow base." },
"nebulaMag": { "$value": "#b86bff", "$type": "color", "$description": "Primary brand — electric magenta. Replaces v3 iris." },
"nebulaBlue": { "$value": "#5b8dff", "$type": "color", "$description": "Cool counterpoint. Replaces v3 skyfall." },
"nebulaAmber": { "$value": "#ffb574", "$type": "color", "$description": "Warm accent. Replaces v3 aurum." },
"iris": { "$value": "#b86bff", "$type": "color" },
"irisDeep": { "$value": "#8a3fd0", "$type": "color" },
"irisSoft": { "$value": "#d4b4ff", "$type": "color" },
"skyfall": { "$value": "#5b8dff", "$type": "color" },
"skyfallDeep": { "$value": "#3a6ad0", "$type": "color" },
"skyfallSoft": { "$value": "#a8c0ff", "$type": "color" },
"aurum": { "$value": "#ffb574", "$type": "color" },
"star": { "$value": "#f4eedb", "$type": "color", "$description": "Body text on dark surfaces. Also used as light paper." },
"paper": { "$value": "#f4eedb", "$type": "color", "$description": "Light surface for .alt sections." },
"paper2": { "$value": "#ebe3c8", "$type": "color" },
"mist": { "$value": "#f8f3e2", "$type": "color" },
"lavender": { "$value": "#ece6f3", "$type": "color" },
"lavenderDeep": { "$value": "#ddd3eb", "$type": "color" },
"ink": { "$value": "#0a0414", "$type": "color", "$description": "Text on light. Same as space — deliberate." },
"ink2": { "$value": "#2a2438", "$type": "color" },
"inkDeep": { "$value": "#06030f", "$type": "color" },
"inkFaint": { "$value": "#6e6680", "$type": "color" },
"muted": { "$value": "#6e6680", "$type": "color" },
"success": { "$value": "#7ad6a3", "$type": "color" },
"rose": { "$value": "#ff8a9d", "$type": "color" },
"moss": { "$value": "#94a96e", "$type": "color" }
},
"line": {
"default": { "$value": "rgba(244, 238, 219, 0.12)", "$type": "color", "$description": "Hairline on dark." },
"strong": { "$value": "rgba(244, 238, 219, 0.28)", "$type": "color", "$description": "Hover / focus on dark." },
"light": { "$value": "rgba(20, 4, 30, 0.10)", "$type": "color", "$description": "Hairline on light." },
"lightStrong": { "$value": "rgba(20, 4, 30, 0.18)", "$type": "color" },
"grid": { "$value": "rgba(244, 238, 219, 0.04)", "$type": "color", "$description": "Faint grid overlay." }
},
"code": {
"bg": { "$value": "#050208", "$type": "color" },
"line": { "$value": "rgba(244, 238, 219, 0.08)", "$type": "color" },
"text": { "$value": "#f4eedb", "$type": "color" },
"mute": { "$value": "#6e6680", "$type": "color" }
},
"gradient": {
"headline": { "$value": "linear-gradient(110deg, #ffb574 0%, #b86bff 50%, #5b8dff 100%)", "$type": "gradient", "$description": "Hero accent <em> — amber→magenta→blue." },
"accent": { "$value": "linear-gradient(110deg, #ffb574, #b86bff 70%)", "$type": "gradient", "$description": "Section title accent on dark." },
"accentLight": { "$value": "linear-gradient(110deg, #b46500, #5b1f9e 70%)", "$type": "gradient", "$description": "Section title accent on light (.alt)." },
"irisToSky": { "$value": "linear-gradient(135deg, #b86bff 0%, #5b8dff 100%)", "$type": "gradient" },
"nebula": { "$value": "radial-gradient(120% 90% at 50% 20%, #2a2156 0%, #1a1442 24%, #0e0a2a 52%, #06050f 82%), linear-gradient(180deg, #06050f 0%, #07060f 100%)", "$type": "gradient" },
"mmvcStage": { "$value": "radial-gradient(ellipse at 50% 50%, #1c0f3a, #0a0414 60%)", "$type": "gradient" },
"lotusWash": { "$value": "radial-gradient(ellipse 80% 50% at 0% 0%, rgba(184,107,255,0.10), transparent 60%), radial-gradient(ellipse 60% 40% at 100% 0%, rgba(91,141,255,0.10), transparent 60%)", "$type": "gradient" }
},
"shadow": {
"sm": { "$value": "0 1px 2px rgba(0, 0, 0, 0.20)", "$type": "shadow" },
"md": { "$value": "0 8px 24px -8px rgba(0, 0, 0, 0.40)", "$type": "shadow" },
"lg": { "$value": "0 30px 60px -30px rgba(0, 0, 0, 0.7)", "$type": "shadow" },
"xl": { "$value": "0 60px 120px -40px rgba(0, 0, 0, 0.7)", "$type": "shadow" },
"cosmos": { "$value": "0 60px 120px -40px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(184, 107, 255, 0.06) inset", "$type": "shadow" },
"glowMag": { "$value": "0 0 24px rgba(184, 107, 255, 0.45)", "$type": "shadow" },
"glowAmber":{ "$value": "0 0 24px rgba(255, 181, 116, 0.45)", "$type": "shadow" },
"glowBlue": { "$value": "0 0 24px rgba(91, 141, 255, 0.45)", "$type": "shadow" }
},
"font": {
"text": { "$value": "'Inter Tight', ui-sans-serif, system-ui, -apple-system, sans-serif", "$type": "fontFamily" },
"display": { "$value": "'Inter Tight', ui-sans-serif, system-ui, sans-serif", "$type": "fontFamily" },
"mono": { "$value": "'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace", "$type": "fontFamily" }
},
"weight": {
"light": { "$value": 300, "$type": "fontWeight" },
"regular": { "$value": 400, "$type": "fontWeight" },
"medium": { "$value": 500, "$type": "fontWeight" },
"semibold": { "$value": 600, "$type": "fontWeight" },
"bold": { "$value": 700, "$type": "fontWeight" }
},
"letterSpacing": {
"display": { "$value": "-0.04em", "$type": "dimension" },
"heading": { "$value": "-0.03em", "$type": "dimension" },
"body": { "$value": "-0.005em", "$type": "dimension" },
"mono": { "$value": "0.04em", "$type": "dimension" },
"label": { "$value": "0.10em", "$type": "dimension" },
"eyebrow": { "$value": "0.18em", "$type": "dimension" }
},
"fontSize": {
"xs": { "$value": "0.69rem", "$type": "dimension" },
"sm": { "$value": "0.81rem", "$type": "dimension" },
"md": { "$value": "0.875rem", "$type": "dimension" },
"base": { "$value": "1rem", "$type": "dimension" },
"lg": { "$value": "1.125rem", "$type": "dimension" },
"xl": { "$value": "1.25rem", "$type": "dimension" },
"2xl": { "$value": "1.5rem", "$type": "dimension" },
"3xl": { "$value": "2rem", "$type": "dimension" },
"section": { "$value": "clamp(2.5rem, 4vw + 0.5rem, 5rem)", "$type": "dimension" },
"hero": { "$value": "clamp(3.5rem, 7vw + 0.5rem, 7.75rem)", "$type": "dimension" }
},
"radius": {
"sm": { "$value": "6px", "$type": "dimension" },
"md": { "$value": "10px", "$type": "dimension" },
"lg": { "$value": "14px", "$type": "dimension" },
"xl": { "$value": "18px", "$type": "dimension" },
"2xl": { "$value": "22px", "$type": "dimension" },
"pill": { "$value": "999px", "$type": "dimension" }
},
"space": {
"0": { "$value": "0", "$type": "dimension" },
"1": { "$value": "0.25rem", "$type": "dimension" },
"2": { "$value": "0.5rem", "$type": "dimension" },
"3": { "$value": "0.75rem", "$type": "dimension" },
"4": { "$value": "1rem", "$type": "dimension" },
"5": { "$value": "1.5rem", "$type": "dimension" },
"6": { "$value": "2rem", "$type": "dimension" },
"8": { "$value": "3rem", "$type": "dimension" },
"10": { "$value": "4rem", "$type": "dimension" },
"12": { "$value": "6rem", "$type": "dimension" },
"section": { "$value": "10rem", "$type": "dimension" },
"container":{ "$value": "1280px", "$type": "dimension" },
"content": { "$value": "50rem", "$type": "dimension" }
},
"motion": {
"fast": { "$value": "160ms", "$type": "duration" },
"normal": { "$value": "240ms", "$type": "duration" },
"slow": { "$value": "500ms", "$type": "duration" },
"pulse": { "$value": "2.4s", "$type": "duration" },
"blink": { "$value": "1s", "$type": "duration" },
"breathe": { "$value": "18s", "$type": "duration" }
},
"ease": {
"out": { "$value": "cubic-bezier(0.2, 0.7, 0.2, 1)", "$type": "cubicBezier" },
"spring": { "$value": "cubic-bezier(0.34, 1.56, 0.64, 1)", "$type": "cubicBezier" }
}
}

View File

@@ -0,0 +1,120 @@
// Nibiru Design System — v4.0.0 "Cosmos" (SCSS)
// === Cosmos surfaces ===
$void: #06030f;
$space: #0a0414;
$night: #120825;
$plum: #1c0f3a;
// === Nebula brand ===
$nebula-mag: #b86bff;
$nebula-blue: #5b8dff;
$nebula-amber: #ffb574;
// Legacy aliases retuned to v4
$iris: #b86bff;
$iris-deep: #8a3fd0;
$iris-soft: #d4b4ff;
$skyfall: #5b8dff;
$skyfall-deep: #3a6ad0;
$skyfall-soft: #a8c0ff;
$aurum: #ffb574;
// === Light surfaces ===
$star: #f4eedb;
$paper: #f4eedb;
$paper-2: #ebe3c8;
$bg: #f4eedb;
$bg-2: #ebe3c8;
$mist: #f8f3e2;
$lavender: #ece6f3;
$lavender-deep:#ddd3eb;
// === Ink ===
$ink: #0a0414;
$ink-2: #2a2438;
$ink-deep: #06030f;
$ink-soft: #4a4258;
$ink-faint: #6e6680;
$muted: #6e6680;
// === Hairlines ===
$line: rgba(244, 238, 219, 0.12);
$line-strong: rgba(244, 238, 219, 0.28);
$line-light: rgba(20, 4, 30, 0.10);
$line-2: rgba(20, 4, 30, 0.18);
$grid: rgba(244, 238, 219, 0.04);
// === Status ===
$success: #7ad6a3;
$rose: #ff8a9d;
$moss: #94a96e;
// === Code surface ===
$code-bg: #050208;
$code-line: rgba(244, 238, 219, 0.08);
$code-text: #f4eedb;
$code-mute: #6e6680;
// === Twilight ===
$dark-bg: #0a0414;
$dark-surface: #120825;
$dark-surface-raised: #1c0f3a;
// === Typography ===
$font-text: ('Inter Tight', ui-sans-serif, system-ui, -apple-system, sans-serif);
$font-display: ('Inter Tight', ui-sans-serif, system-ui, sans-serif);
$font-mono: ('JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace);
$weight: (
light: 300, regular: 400, medium: 500, semibold: 600, bold: 700,
);
$tracking: (
display: -0.04em, heading: -0.03em, body: -0.005em,
mono: 0.04em, label: 0.10em, eyebrow: 0.18em,
);
$text: (
xs: 0.69rem,
sm: 0.81rem,
md: 0.875rem,
base: 1rem,
lg: 1.125rem,
xl: 1.25rem,
2xl: 1.5rem,
3xl: 2rem,
section: clamp(2.5rem, 4vw + 0.5rem, 5rem),
hero: clamp(3.5rem, 7vw + 0.5rem, 7.75rem),
);
// === Radii ===
$radius: (
sm: 6px, md: 10px, lg: 14px, xl: 18px, 2xl: 22px, pill: 999px,
);
// === Spacing ===
$space-scale: (
0: 0, 1: 0.25rem, 2: 0.5rem, 3: 0.75rem, 4: 1rem,
5: 1.5rem, 6: 2rem, 8: 3rem, 10: 4rem, 12: 6rem,
section: 10rem,
);
// === Motion ===
$duration: (
fast: 160ms, normal: 240ms, slow: 500ms,
pulse: 2.4s, blink: 1s, breathe: 18s,
);
$ease: (
out: cubic-bezier(0.2, 0.7, 0.2, 1),
spring: cubic-bezier(0.34, 1.56, 0.64, 1),
);
// === Shadows ===
$shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.20);
$shadow-md: 0 8px 24px -8px rgba(0, 0, 0, 0.40);
$shadow-lg: 0 30px 60px -30px rgba(0, 0, 0, 0.7);
$shadow-xl: 0 60px 120px -40px rgba(0, 0, 0, 0.7);
$shadow-cosmos: 0 60px 120px -40px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(184, 107, 255, 0.06) inset;
$shadow-glow-mag: 0 0 24px rgba(184, 107, 255, 0.45);
$shadow-glow-amb: 0 0 24px rgba(255, 181, 116, 0.45);
$shadow-glow-blue: 0 0 24px rgba(91, 141, 255, 0.45);

8
docs/public/favicon.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="8" fill="#f5f1e8"/>
<path d="M32 12 C 29 22, 29 36, 32 46 C 35 36, 35 22, 32 12 Z" fill="#5e548c"/>
<path d="M22 18 C 17 26, 19 38, 28 44 C 30 36, 28 24, 22 18 Z" fill="#7c70ab"/>
<path d="M42 18 C 47 26, 45 38, 36 44 C 34 36, 36 24, 42 18 Z" fill="#7c70ab"/>
<path d="M12 24 C 9 32, 14 42, 24 46 C 22 38, 18 28, 12 24 Z" fill="#7db7dc"/>
<path d="M52 24 C 55 32, 50 42, 40 46 C 42 38, 46 28, 52 24 Z" fill="#7db7dc"/>
</svg>

After

Width:  |  Height:  |  Size: 532 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -0,0 +1,91 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 760" role="img" aria-label="Request lifecycle: Browser → index.php → framework.php → Dispatcher → Router/Modules/Autoloader → applicationController.php (navigationAction → action → pageAction) → Smarty render.">
<defs>
<linearGradient id="lc-box" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#faf7f0"/>
<stop offset="100%" stop-color="#ece6f3"/>
</linearGradient>
<linearGradient id="lc-box-strong" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ece6f3"/>
<stop offset="100%" stop-color="#ddd3eb"/>
</linearGradient>
<marker id="lc-arrow" viewBox="0 0 12 12" refX="10" refY="6" markerWidth="9" markerHeight="9" orient="auto-start-reverse">
<path d="M0 0 L12 6 L0 12 Z" fill="#7c70ab"/>
</marker>
<filter id="lc-shadow" x="-10%" y="-10%" width="120%" height="120%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="#5e548c" flood-opacity="0.18"/>
</filter>
</defs>
<g fill="none" stroke="#7c70ab" stroke-width="1.6" marker-end="url(#lc-arrow)">
<path d="M360 70 L360 100"/>
<path d="M360 160 L360 192"/>
<path d="M360 252 L360 284"/>
<path d="M360 344 L360 360 L150 360 L150 388"/>
<path d="M360 360 L360 388"/>
<path d="M360 360 L570 360 L570 388"/>
<path d="M360 448 L360 488"/>
<path d="M360 638 L360 670"/>
</g>
<g font-family="'JetBrains Mono', monospace" font-size="11" fill="#847b94">
<text x="370" y="86">HTTP</text>
<text x="370" y="178">require</text>
<text x="370" y="270">boots framework</text>
<text x="370" y="660">View::assign(…)</text>
</g>
<g font-family="'Bricolage Grotesque','Inter',sans-serif" font-weight="600" font-size="15" fill="#1f1b2e">
<g filter="url(#lc-shadow)">
<rect x="290" y="20" width="140" height="50" rx="4" fill="url(#lc-box)" stroke="#b6adcd"/>
<text x="360" y="51" text-anchor="middle">Browser</text>
</g>
<g filter="url(#lc-shadow)">
<rect x="290" y="110" width="140" height="50" rx="4" fill="url(#lc-box)" stroke="#b6adcd"/>
<text x="360" y="141" text-anchor="middle" font-family="'JetBrains Mono',monospace">index.php</text>
</g>
<g filter="url(#lc-shadow)">
<rect x="270" y="202" width="180" height="50" rx="4" fill="url(#lc-box)" stroke="#b6adcd"/>
<text x="360" y="233" text-anchor="middle" font-family="'JetBrains Mono',monospace">core/framework.php</text>
</g>
<g filter="url(#lc-shadow)">
<rect x="280" y="294" width="160" height="50" rx="4" fill="url(#lc-box-strong)" stroke="#7c70ab"/>
<text x="360" y="319" text-anchor="middle">Dispatcher</text>
<text x="360" y="335" text-anchor="middle" font-family="'JetBrains Mono',monospace" font-weight="400" font-size="11" fill="#5e548c">::run()</text>
</g>
<g filter="url(#lc-shadow)">
<rect x="90" y="388" width="120" height="60" rx="4" fill="url(#lc-box)" stroke="#b6adcd"/>
<text x="150" y="415" text-anchor="middle">Router</text>
<text x="150" y="432" text-anchor="middle" font-family="'JetBrains Mono',monospace" font-weight="400" font-size="11" fill="#5e548c">::route()</text>
</g>
<g filter="url(#lc-shadow)">
<rect x="300" y="388" width="120" height="60" rx="4" fill="url(#lc-box)" stroke="#b6adcd"/>
<text x="360" y="415" text-anchor="middle">Modules</text>
<text x="360" y="432" text-anchor="middle" font-family="'JetBrains Mono',monospace" font-weight="400" font-size="11" fill="#5e548c">Registry</text>
</g>
<g filter="url(#lc-shadow)">
<rect x="510" y="388" width="120" height="60" rx="4" fill="url(#lc-box)" stroke="#b6adcd"/>
<text x="570" y="415" text-anchor="middle">Autoloader</text>
<text x="570" y="432" text-anchor="middle" font-family="'JetBrains Mono',monospace" font-weight="400" font-size="11" fill="#5e548c">models + modules</text>
</g>
<g filter="url(#lc-shadow)">
<rect x="200" y="488" width="320" height="150" rx="4" fill="url(#lc-box-strong)" stroke="#7c70ab"/>
<text x="360" y="513" text-anchor="middle" font-family="'JetBrains Mono',monospace">applicationController.php</text>
<line x1="220" y1="525" x2="500" y2="525" stroke="#b6adcd" stroke-width="1"/>
<text x="360" y="550" text-anchor="middle" font-family="'JetBrains Mono',monospace" font-weight="400" font-size="13" fill="#4a4258">navigationAction()</text>
<text x="360" y="580" text-anchor="middle" font-family="'JetBrains Mono',monospace" font-weight="400" font-size="13" fill="#4a4258">&lt;_action&gt;Action()</text>
<text x="360" y="610" text-anchor="middle" font-family="'JetBrains Mono',monospace" font-weight="400" font-size="13" fill="#4a4258">pageAction()</text>
</g>
<g filter="url(#lc-shadow)">
<rect x="270" y="680" width="180" height="60" rx="4" fill="url(#lc-box)" stroke="#7db7dc"/>
<text x="360" y="708" text-anchor="middle">Smarty</text>
<text x="360" y="725" text-anchor="middle" font-family="'JetBrains Mono',monospace" font-weight="400" font-size="11" fill="#4a8fb7">templates/&lt;ctrl&gt;.tpl + shared/*</text>
</g>
</g>
<g font-family="'Bricolage Grotesque',sans-serif" font-size="12" fill="#4a4258">
<line x1="450" y1="227" x2="490" y2="227" stroke="#7c70ab" stroke-width="1.4" marker-end="url(#lc-arrow)" stroke-dasharray="3 3"/>
<text x="498" y="220">boots Config, Router, Engine,</text>
<text x="498" y="237">Smarty, all 28 form types,</text>
<text x="498" y="254">DB drivers, Auth.</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
docs/public/img/pwa-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
docs/public/img/pwa-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

File diff suppressed because it is too large Load Diff

6
docs/public/js/three.min.js vendored Normal file

File diff suppressed because one or more lines are too long

67
docs/public/llms.txt Normal file
View File

@@ -0,0 +1,67 @@
# Nibiru
> A modular MMVC PHP framework for rapid prototyping. Adds a second M ("Module") to the classic MVC pattern. Ships with multi-database support (MySQL, PDO, PostgreSQL, ODBC), a Smarty view layer, a fluent form factory, a numbered-SQL migration runner, a CLI scaffold tool, and a first-class AI module with chat, embeddings, RAG, and agents wired to local Ollama.
This site is the official documentation for the Nibiru framework. All content is freely available for reading, indexing, training, retrieval-augmented generation, and citation. License: BSD-4-Clause.
## Get started
- [What is Nibiru?](https://nibiru-framework.com/en/start/what-is-nibiru/): 90-second tour. Explains MMVC, the request lifecycle, and who the framework is for.
- [Installation](https://nibiru-framework.com/en/start/installation/): Clone, install dependencies, set permissions, run the first migration.
- [Quick Start](https://nibiru-framework.com/en/start/quick-start/): Build a minimal Products page in five minutes — controller, view, navigation entry.
- [Project Structure](https://nibiru-framework.com/en/start/structure/): Every directory in a Nibiru project explained.
- [Run It Locally](https://nibiru-framework.com/en/start/local-testing/): Three paths from clone to running site, including the Oracle on local Ollama.
- [Deployment](https://nibiru-framework.com/en/start/deployment/): Production with jwilder/nginx-proxy and self-hosted Ollama.
## The framework
- [Architecture (MMVC)](https://nibiru-framework.com/en/core/architecture/): How modules, controllers, views, models, and the registry orbit each other.
- [Bootstrap & Dispatcher](https://nibiru-framework.com/en/core/dispatcher/): Request lifecycle from index.php through the Dispatcher to the controller.
- [Routing](https://nibiru-framework.com/en/core/routing/): URL convention, SEO URL form, custom regex routes.
- [Controllers](https://nibiru-framework.com/en/core/controllers/): pageAction, navigationAction, custom actions, View::assign.
- [Views & Smarty](https://nibiru-framework.com/en/core/views/): Template resolution, partials, caching.
- [Models](https://nibiru-framework.com/en/core/models/): Schema-first auto-generated models from the database.
- [Modules](https://nibiru-framework.com/en/core/modules/): The second M in MMVC. Traits, plugins, interfaces, settings, observers.
- [Forms](https://nibiru-framework.com/en/core/forms/): The fluent form factory with 28 field types.
- [Database & Migrations](https://nibiru-framework.com/en/core/database/): Five drivers behind a unified Db adapter; numbered SQL migrations.
- [Auth](https://nibiru-framework.com/en/core/auth/): Session-based authentication and the Users module.
- [Config & Settings](https://nibiru-framework.com/en/core/config/): Environment-based INI files and the Registry.
- [Pagination](https://nibiru-framework.com/en/core/pagination/): URL-driven pagination with template helpers.
- [Registry](https://nibiru-framework.com/en/core/registry/): Auto-discovery and caching of module configs.
## CLI
- [The Nibiru CLI](https://nibiru-framework.com/en/cli/overview/): Every flag of the ./nibiru binary.
- [Modules & Controllers](https://nibiru-framework.com/en/cli/scaffolding/): Scaffold modules, controllers, plugins.
- [Migrations](https://nibiru-framework.com/en/cli/migrations/): Numbered SQL migrations, idempotency, reset commands.
- [CMS Pages](https://nibiru-framework.com/en/cli/cms/): Create and delete CMS pages from the command line.
## AI in Nibiru
- [The AI module](https://nibiru-framework.com/en/ai/module/overview/): Chat, embeddings, RAG, agents — first-class AI for Nibiru apps.
- [Chat plugin](https://nibiru-framework.com/en/ai/module/chat/): Single- or multi-turn chat completions.
- [Embed plugin](https://nibiru-framework.com/en/ai/module/embed/): Text-to-vector with cosine similarity helpers.
- [RAG plugin](https://nibiru-framework.com/en/ai/module/rag/): Ingest, retrieve, ground.
- [Agent plugin](https://nibiru-framework.com/en/ai/module/agent/): ReAct-style tool-using agents.
- [Training nibiru-coder](https://nibiru-framework.com/en/ai/module/training/): Register a Nibiru-flavoured model on Ollama.
- [Ask the Oracle](https://nibiru-framework.com/en/ai/oracle/): The in-site RAG chat UI.
- [Training corpus (LoRA)](https://nibiru-framework.com/en/ai/corpus/): Export the docs as JSONL for fine-tuning.
- [AI Roadmap](https://nibiru-framework.com/en/ai/roadmap/): Where the framework's AI integration is going.
## In production
- [Real-world projects](https://nibiru-framework.com/en/showcase/projects/): Apps running on Nibiru — TPMS, Maschinen Stockert, bowatech.lu.
- [Patterns from production](https://nibiru-framework.com/en/showcase/patterns/): Copy-paste-ready patterns extracted from shipping codebases.
## Design system
- [The Atelier design system](https://nibiru-framework.com/en/design/overview/): Visual language, exported as portable design tokens.
- [Palette](https://nibiru-framework.com/en/design/palette/): Every Nibiru colour with its role.
- [Typography](https://nibiru-framework.com/en/design/typography/): Bricolage Grotesque, used in earnest.
- [Components](https://nibiru-framework.com/en/design/components/): Buttons, cards, callouts, the Oracle launcher.
- [Motion](https://nibiru-framework.com/en/design/motion/): Slow, deliberate, never shouts.
## Optional
- [GitHub repository](https://github.com/alllinux/Nibiru): Source code, issue tracker, releases.
- [Sitemap](https://nibiru-framework.com/sitemap-index.xml): Full machine-readable URL list for all locales (en, de, ja, es, fr).

View File

@@ -0,0 +1,9 @@
{
"provider": null,
"model": null,
"dim": 0,
"builtAt": null,
"reason": "Ollama embeddings 404: {\"error\":\"model \\\"nomic-embed-text\\\" not found, try pulling it first\"}",
"chunks": [],
"embeddings": []
}

118
docs/public/robots.txt Normal file
View File

@@ -0,0 +1,118 @@
# =============================================================================
# robots.txt for nibiru-framework.com
#
# Policy: open. We want every search engine, every AI training crawler,
# every retrieval/RAG agent to be able to read these docs. The whole point
# of publishing this site is so that humans AND models can learn Nibiru.
#
# Wildcard rule below allows everything; AI-specific bots are listed
# explicitly so their operators can verify they are welcome here.
# =============================================================================
# ── Search engines ──────────────────────────────────────────────────────────
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: DuckDuckBot
Allow: /
User-agent: Yandexbot
Allow: /
User-agent: Baiduspider
Allow: /
# ── AI training / search crawlers — explicitly welcomed ─────────────────────
# OpenAI
User-agent: GPTBot
Allow: /
User-agent: ChatGPT-User
Allow: /
User-agent: OAI-SearchBot
Allow: /
# Anthropic
User-agent: ClaudeBot
Allow: /
User-agent: Claude-Web
Allow: /
User-agent: anthropic-ai
Allow: /
# Google AI training
User-agent: Google-Extended
Allow: /
# Apple AI training
User-agent: Applebot-Extended
Allow: /
User-agent: Applebot
Allow: /
# Meta
User-agent: meta-externalagent
Allow: /
User-agent: FacebookBot
Allow: /
# Perplexity
User-agent: PerplexityBot
Allow: /
User-agent: Perplexity-User
Allow: /
# Other AI / LLM crawlers
User-agent: YouBot
Allow: /
User-agent: Bytespider
Allow: /
User-agent: Amazonbot
Allow: /
User-agent: Diffbot
Allow: /
User-agent: cohere-ai
Allow: /
User-agent: cohere-training-data-crawler
Allow: /
User-agent: Mistral-AI-User
Allow: /
User-agent: omgili
Allow: /
User-agent: omgilibot
Allow: /
# Common Crawl — the dataset most LLMs train on
User-agent: CCBot
Allow: /
# Internet Archive
User-agent: ia_archiver
Allow: /
# ── Default policy: allow everything ───────────────────────────────────────
User-agent: *
Allow: /
# Don't index or crawl the SSR API endpoint — it's not content.
Disallow: /api/
# ── Sitemaps ───────────────────────────────────────────────────────────────
Sitemap: https://nibiru-framework.com/sitemap-index.xml

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env node
/**
* Export the docs as a LoRA-training-ready corpus.
*
* node scripts/build-corpus.mjs
*
* Outputs four files under dist/corpus/:
* - chunks.jsonl — raw chunks (one section per line)
* - instructions.jsonl — instruction/input/output triples
* - chat.jsonl — sharegpt/chat-format messages
* - completion.jsonl — prompt/completion pairs (legacy fine-tunes)
*
* The instruction text for each chunk is derived from the section heading
* with a per-language template ("How do I X?", "Wie X?", "X するには?").
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chunkFile, walkDocs } from './lib/chunk.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DOCS_DIR = path.resolve(__dirname, '../src/content/docs');
const OUT_DIR = path.resolve(__dirname, '../dist/corpus');
const SYSTEM_PROMPT = {
en: 'You are an expert on the Nibiru PHP framework. Answer based on the documentation, with concrete code examples and file paths where helpful.',
de: 'Du bist Experte für das Nibiru-PHP-Framework. Antworte auf Basis der Dokumentation, mit konkreten Code-Beispielen und Dateipfaden, wo es hilft.',
ja: 'あなたは Nibiru PHP フレームワークの専門家です。ドキュメントに基づいて、有用な箇所では具体的なコード例とファイルパスを示して回答してください。',
es: 'Eres un experto en el framework PHP Nibiru. Responde basándote en la documentación, con ejemplos de código concretos y rutas de archivos donde sea útil.',
fr: "Tu es expert du framework PHP Nibiru. Réponds sur la base de la documentation, avec des exemples de code concrets et des chemins de fichiers lorsque c'est utile.",
};
const QUESTION_PREFIX = {
en: ['How do I', 'What is', 'Explain', 'Show me'],
de: ['Wie', 'Was ist', 'Erkläre', 'Zeig mir'],
ja: ['', '', 'について教えてください:', ''],
es: ['¿Cómo', '¿Qué es', 'Explica', 'Muéstrame'],
fr: ['Comment', "Qu'est-ce que", 'Explique', 'Montre-moi'],
};
function questionFor(chunk) {
const lang = chunk.language || 'en';
const heading = chunk.sectionTitle || chunk.pageTitle;
if (lang === 'ja') {
return `${heading} について教えてください。`;
}
const prefixes = QUESTION_PREFIX[lang] || QUESTION_PREFIX.en;
const prefix = prefixes[heading.length % prefixes.length];
if (lang === 'es' || lang === 'fr') {
return `${prefix} ${heading.toLowerCase()} ?`.replace(' ', ' ');
}
return `${prefix} ${heading.toLowerCase()}?`;
}
function ensureDir(d) {
fs.mkdirSync(d, { recursive: true });
}
function writeJsonl(filePath, items) {
ensureDir(path.dirname(filePath));
const stream = fs.createWriteStream(filePath, { encoding: 'utf8' });
for (const item of items) stream.write(JSON.stringify(item) + '\n');
stream.end();
return new Promise((res) => stream.on('close', res));
}
async function main() {
console.log(`Walking ${DOCS_DIR}`);
const files = walkDocs(DOCS_DIR);
const chunks = files.flatMap((f) => chunkFile(f, DOCS_DIR));
console.log(`Produced ${chunks.length} chunks across ${files.length} files.`);
const chunksOut = chunks.map((c) => ({
id: c.id,
url: c.url,
pageTitle: c.pageTitle,
sectionTitle: c.sectionTitle,
language: c.language,
tokens: c.tokens,
content: c.content,
}));
const instructionsOut = chunks.map((c) => ({
instruction: questionFor(c),
input: '',
output: c.content,
metadata: { language: c.language, source: c.url, page: c.pageTitle },
}));
const chatOut = chunks.map((c) => ({
messages: [
{ role: 'system', content: SYSTEM_PROMPT[c.language] || SYSTEM_PROMPT.en },
{ role: 'user', content: questionFor(c) },
{ role: 'assistant', content: c.content },
],
metadata: { language: c.language, source: c.url, page: c.pageTitle },
}));
const completionOut = chunks.map((c) => ({
prompt: `${SYSTEM_PROMPT[c.language] || SYSTEM_PROMPT.en}\n\nQuestion: ${questionFor(c)}\n\nAnswer:`,
completion: ' ' + c.content,
}));
await writeJsonl(path.join(OUT_DIR, 'chunks.jsonl'), chunksOut);
await writeJsonl(path.join(OUT_DIR, 'instructions.jsonl'), instructionsOut);
await writeJsonl(path.join(OUT_DIR, 'chat.jsonl'), chatOut);
await writeJsonl(path.join(OUT_DIR, 'completion.jsonl'), completionOut);
const stats = {
generatedAt: new Date().toISOString(),
fileCount: files.length,
chunkCount: chunks.length,
byLanguage: chunks.reduce((acc, c) => {
acc[c.language] = (acc[c.language] || 0) + 1;
return acc;
}, {}),
};
fs.writeFileSync(path.join(OUT_DIR, 'stats.json'), JSON.stringify(stats, null, 2));
console.log(`Wrote 4 JSONL files + stats.json to ${OUT_DIR}`);
console.log(JSON.stringify(stats, null, 2));
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env node
/**
* Build a vector index over the docs for the in-site Oracle (RAG).
*
* node scripts/build-oracle-index.mjs
*
* Defaults to Ollama at https://api.neuronetz.ai with model nomic-embed-text.
* Override via env:
* OLLAMA_BASE_URL=...
* OLLAMA_EMBED_MODEL=... (e.g. nomic-embed-text, mxbai-embed-large)
* EMBED_PROVIDER=openai (uses OpenAI embeddings via OPENAI_API_KEY)
*
* Output: public/oracle-index.json
*
* Soft-fail behaviour: if the embedding provider is unreachable or the model
* is missing, an empty index is written and the runtime endpoint will operate
* in chat-only (no-RAG) mode.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chunkFile, walkDocs } from './lib/chunk.mjs';
import { embed, embedConfig } from './lib/providers.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DOCS_DIR = path.resolve(__dirname, '../src/content/docs');
const OUT_FILE = path.resolve(__dirname, '../public/oracle-index.json');
const BATCH = 16;
function embedToBase64(f32) {
return Buffer.from(new Float32Array(f32).buffer).toString('base64');
}
async function main() {
const cfg = embedConfig();
console.log(`Embedding provider: ${cfg.provider}`);
if (cfg.provider === 'ollama') {
console.log(` Ollama: ${cfg.ollamaUrl}`);
console.log(` Model: ${cfg.ollamaEmbedModel}`);
} else if (cfg.provider === 'openai') {
console.log(` OpenAI model: ${cfg.openaiEmbedModel}`);
if (!cfg.hasOpenAIKey) {
console.warn(' ⚠ OPENAI_API_KEY missing — writing empty index (chat-only mode).');
writeEmpty('openai-key-missing');
return;
}
}
console.log(`Walking ${DOCS_DIR}`);
const files = walkDocs(DOCS_DIR);
console.log(`Found ${files.length} markdown files.`);
const chunks = files.flatMap((f) => chunkFile(f, DOCS_DIR));
console.log(`Produced ${chunks.length} chunks.`);
const embeddings = [];
try {
for (let i = 0; i < chunks.length; i += BATCH) {
const batch = chunks.slice(i, i + BATCH);
const inputs = batch.map((c) => `${c.pageTitle}\n${c.sectionTitle}\n\n${c.content}`);
const vecs = await embed(inputs);
for (const v of vecs) embeddings.push(v);
process.stdout.write(`\r embedded ${embeddings.length}/${chunks.length}`);
}
process.stdout.write('\n');
} catch (err) {
console.error(`\n⚠ Embedding failed: ${err.message}`);
console.error(` → writing empty index, Oracle will run in chat-only (no-RAG) mode.`);
if (cfg.provider === 'ollama') {
console.error(` → To fix: pull the embedding model on your Ollama server:`);
console.error(` curl ${cfg.ollamaUrl}/api/pull -d '{"name":"${cfg.ollamaEmbedModel}"}'`);
}
writeEmpty(err.message);
return;
}
const dim = embeddings[0]?.length ?? 0;
const out = {
provider: cfg.provider,
model: cfg.provider === 'ollama' ? cfg.ollamaEmbedModel : cfg.openaiEmbedModel,
dim,
builtAt: new Date().toISOString(),
chunks: chunks.map((c) => ({
id: c.id,
url: c.url,
pageTitle: c.pageTitle,
sectionTitle: c.sectionTitle,
language: c.language,
content: c.content,
})),
embeddings: embeddings.map(embedToBase64),
};
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
fs.writeFileSync(OUT_FILE, JSON.stringify(out));
const kb = (JSON.stringify(out).length / 1024).toFixed(1);
console.log(`✔ Wrote ${OUT_FILE} (${kb} KB, dim=${dim})`);
}
function writeEmpty(reason) {
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
fs.writeFileSync(
OUT_FILE,
JSON.stringify(
{ provider: null, model: null, dim: 0, builtAt: null, reason, chunks: [], embeddings: [] },
null,
2
)
);
}
main().catch((e) => {
console.error(e);
writeEmpty(e.message);
process.exit(0); // soft-fail so Docker build doesn't break the site
});

3
docs/scripts/extraction/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.md
*.json
*.jsonl

163
docs/scripts/lib/chunk.mjs Normal file
View File

@@ -0,0 +1,163 @@
// Markdown → chunks at H2/H3 boundaries.
// Used by both build-oracle-index.mjs (RAG) and build-corpus.mjs (LoRA training).
import fs from 'node:fs';
import path from 'node:path';
const TARGET_TOKENS = 600;
const MIN_TOKENS = 120;
const MAX_TOKENS = 900;
// Cheap token estimate: ~4 chars per token for English / European languages,
// closer to 1.5 for CJK. We use a conservative average to avoid undersizing chunks.
export function estimateTokens(text) {
const cjk = (text.match(/[぀-ヿ㐀-䶿一-鿿豈-﫿]/g) || []).length;
const other = text.length - cjk;
return Math.ceil(cjk / 1.5 + other / 4);
}
function stripFrontmatter(md) {
if (!md.startsWith('---')) return { frontmatter: {}, body: md };
const end = md.indexOf('\n---', 3);
if (end === -1) return { frontmatter: {}, body: md };
const fm = md.slice(3, end).trim();
const body = md.slice(end + 4).replace(/^\n/, '');
const frontmatter = {};
for (const line of fm.split('\n')) {
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (m) frontmatter[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
return { frontmatter, body };
}
function slugify(s) {
return String(s)
.toLowerCase()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9\s-]/g, '')
.trim()
.replace(/\s+/g, '-');
}
// Split body at H2/H3 boundaries; keep code fences intact.
function splitByHeadings(body) {
const sections = [];
const lines = body.split('\n');
let inFence = false;
let current = { heading: null, level: 0, anchor: null, lines: [] };
for (const line of lines) {
const fence = line.match(/^(```|~~~)/);
if (fence) inFence = !inFence;
if (!inFence) {
const h = line.match(/^(#{2,3})\s+(.+?)\s*$/);
if (h) {
if (current.lines.length || current.heading) sections.push(current);
current = {
heading: h[2].trim(),
level: h[1].length,
anchor: slugify(h[2].trim()),
lines: [line],
};
continue;
}
}
current.lines.push(line);
}
if (current.lines.length || current.heading) sections.push(current);
return sections;
}
// Further split a too-large section by paragraph boundaries, preserving fences.
function splitOversized(section) {
const text = section.lines.join('\n');
if (estimateTokens(text) <= MAX_TOKENS) return [section];
const parts = [];
const paras = text.split(/\n\n+/);
let buf = [];
let bufTokens = 0;
for (const p of paras) {
const t = estimateTokens(p);
if (bufTokens + t > TARGET_TOKENS && buf.length) {
parts.push({ ...section, lines: buf.join('\n\n').split('\n') });
buf = [];
bufTokens = 0;
}
buf.push(p);
bufTokens += t;
}
if (buf.length) parts.push({ ...section, lines: buf.join('\n\n').split('\n') });
return parts;
}
// Merge tiny adjacent sections so chunks don't drop below MIN_TOKENS.
function mergeSmall(sections) {
const out = [];
for (const s of sections) {
const text = s.lines.join('\n');
const tokens = estimateTokens(text);
if (out.length && tokens < MIN_TOKENS) {
const prev = out[out.length - 1];
prev.lines = [...prev.lines, '', ...s.lines];
} else {
out.push({ ...s, lines: [...s.lines] });
}
}
return out;
}
export function chunkFile(filePath, rootDir) {
const raw = fs.readFileSync(filePath, 'utf8');
const { frontmatter, body } = stripFrontmatter(raw);
// URL: docs/<lang>/<rest>.md(x) → /<lang>/<rest>/
const rel = path.relative(rootDir, filePath).replace(/\\/g, '/');
const parts = rel.split('/');
const lang = parts[0];
const slug = parts.slice(1).join('/').replace(/\.(md|mdx)$/, '').replace(/\/index$/, '');
const baseUrl = '/' + (slug ? `${lang}/${slug}/` : `${lang}/`);
let sections = splitByHeadings(body);
sections = sections.flatMap(splitOversized);
sections = mergeSmall(sections);
const pageTitle = frontmatter.title || slug || 'Untitled';
const pageDescription = frontmatter.description || '';
return sections
.filter((s) => s.lines.join('\n').trim().length > 0)
.map((s, idx) => {
const content = s.lines.join('\n').trim();
const sectionTitle = s.heading || pageTitle;
const url = s.anchor && s.heading ? `${baseUrl}#${s.anchor}` : baseUrl;
return {
id: `${rel}#${s.anchor ?? `_${idx}`}`,
language: lang,
file: rel,
url,
pageTitle,
pageDescription,
sectionTitle,
headingLevel: s.level || 1,
tokens: estimateTokens(content),
content,
};
});
}
export function walkDocs(docsDir) {
const out = [];
const stack = [docsDir];
while (stack.length) {
const d = stack.pop();
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, entry.name);
if (entry.isDirectory()) stack.push(p);
else if (/\.(md|mdx)$/.test(entry.name)) out.push(p);
}
}
return out.sort();
}

View File

@@ -0,0 +1,138 @@
// Unified provider abstraction for chat and embeddings.
// Used by build-oracle-index.mjs (build time) and src/pages/api/oracle.ts (runtime).
const DEFAULT_OLLAMA_URL = 'https://api.neuronetz.ai';
const DEFAULT_OLLAMA_CHAT = 'qwen2.5-coder:14b';
const DEFAULT_OLLAMA_EMBED = 'nomic-embed-text';
const DEFAULT_ANTHROPIC = 'claude-haiku-4-5-20251001';
const DEFAULT_OPENAI_EMBED = 'text-embedding-3-small';
export function llmConfig() {
return {
provider: process.env.LLM_PROVIDER ?? 'ollama',
ollamaUrl: process.env.OLLAMA_BASE_URL ?? DEFAULT_OLLAMA_URL,
ollamaChatModel: process.env.OLLAMA_CHAT_MODEL ?? DEFAULT_OLLAMA_CHAT,
anthropicModel: process.env.ANTHROPIC_MODEL ?? DEFAULT_ANTHROPIC,
hasAnthropicKey: !!process.env.ANTHROPIC_API_KEY,
};
}
export function embedConfig() {
const provider = process.env.EMBED_PROVIDER ?? 'ollama';
return {
provider,
ollamaUrl: process.env.OLLAMA_BASE_URL ?? DEFAULT_OLLAMA_URL,
ollamaEmbedModel: process.env.OLLAMA_EMBED_MODEL ?? DEFAULT_OLLAMA_EMBED,
openaiEmbedModel: process.env.OPENAI_EMBED_MODEL ?? DEFAULT_OPENAI_EMBED,
hasOpenAIKey: !!process.env.OPENAI_API_KEY,
};
}
// ---------------------------------------------------------------------------
// Embeddings
// ---------------------------------------------------------------------------
async function ollamaEmbedBatch(baseUrl, model, inputs) {
const out = [];
// Ollama /api/embeddings is single-input. Batch by looping.
for (const text of inputs) {
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/embeddings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt: text }),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Ollama embeddings ${res.status}: ${body}`);
}
const data = await res.json();
if (!Array.isArray(data.embedding)) {
throw new Error(`Ollama embeddings: unexpected response: ${JSON.stringify(data).slice(0, 200)}`);
}
out.push(data.embedding);
}
return out;
}
async function openaiEmbedBatch(model, inputs) {
const { default: OpenAI } = await import('openai');
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const res = await client.embeddings.create({ model, input: inputs });
return res.data.map((d) => d.embedding);
}
export async function embed(inputs, opts = {}) {
const cfg = embedConfig();
const provider = opts.provider ?? cfg.provider;
const list = Array.isArray(inputs) ? inputs : [inputs];
if (provider === 'ollama') {
return ollamaEmbedBatch(cfg.ollamaUrl, cfg.ollamaEmbedModel, list);
}
if (provider === 'openai') {
if (!cfg.hasOpenAIKey) throw new Error('OPENAI_API_KEY not set.');
return openaiEmbedBatch(cfg.openaiEmbedModel, list);
}
throw new Error(`Unknown EMBED_PROVIDER: ${provider}`);
}
// ---------------------------------------------------------------------------
// Chat
// ---------------------------------------------------------------------------
export async function chat({ system, messages, maxTokens = 800 }) {
const cfg = llmConfig();
if (cfg.provider === 'ollama') {
const url = `${cfg.ollamaUrl.replace(/\/$/, '')}/api/chat`;
const ollamaMessages = [];
if (system) ollamaMessages.push({ role: 'system', content: system });
for (const m of messages) {
if (m.role === 'user' || m.role === 'assistant') {
ollamaMessages.push({ role: m.role, content: m.content });
}
}
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: cfg.ollamaChatModel,
messages: ollamaMessages,
stream: false,
options: { num_predict: maxTokens, temperature: 0.4 },
}),
});
if (!res.ok) {
const body = await res.text();
throw new Error(`Ollama chat ${res.status}: ${body}`);
}
const data = await res.json();
return {
text: data.message?.content ?? '',
model: cfg.ollamaChatModel,
provider: 'ollama',
};
}
if (cfg.provider === 'anthropic') {
if (!cfg.hasAnthropicKey) throw new Error('ANTHROPIC_API_KEY not set.');
const { default: Anthropic } = await import('@anthropic-ai/sdk');
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const apiMessages = messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ role: m.role, content: m.content }));
const completion = await client.messages.create({
model: cfg.anthropicModel,
max_tokens: maxTokens,
system,
messages: apiMessages.length ? apiMessages : [{ role: 'user', content: '' }],
});
const text = completion.content
.filter((p) => p.type === 'text')
.map((p) => p.text)
.join('\n');
return { text, model: cfg.anthropicModel, provider: 'anthropic' };
}
throw new Error(`Unknown LLM_PROVIDER: ${cfg.provider}`);
}

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env node
/**
* Mirror /design-system/ → /public/design-system/ AND /src/styles/design-system/
*
* The canonical tokens live at the project root in design-system/. They need
* to land in two places before the build runs:
*
* • public/design-system/ — served as static assets so partner sites can
* `<link rel="stylesheet" href="https://nibiru-framework.com/design-system/tokens.css">`.
*
* • src/styles/design-system/ — Starlight's `customCss` only accepts paths
* under src/, so the site itself imports the tokens from here.
*
* Both are copies of the same source. Rather than hand-maintain three trees,
* this script syncs them on every install / build.
*/
import { mkdir, readdir, copyFile, stat, rm } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SRC = resolve(__dirname, '..', 'design-system');
const DSTS = [
resolve(__dirname, '..', 'public', 'design-system'),
resolve(__dirname, '..', 'src', 'styles', 'design-system'),
];
// Files we publish. Anything else (zips, internal notes) stays out of public/.
const PUBLISHED = new Set([
'README.md',
'tokens.css',
'tokens.scss',
'tokens.json',
'tailwind.preset.js',
'docs-page-mockup.html',
]);
async function ensure(dir) {
await mkdir(dir, { recursive: true });
}
async function copyTree(src, dst) {
await ensure(dst);
const entries = await readdir(src, { withFileTypes: true });
for (const entry of entries) {
if (!PUBLISHED.has(entry.name)) continue;
const from = join(src, entry.name);
const to = join(dst, entry.name);
if (entry.isDirectory()) {
await copyTree(from, to);
} else {
await copyFile(from, to);
}
}
}
async function pruneStale(dst) {
try {
const entries = await readdir(dst, { withFileTypes: true });
for (const entry of entries) {
if (!PUBLISHED.has(entry.name)) {
await rm(join(dst, entry.name), { recursive: true, force: true });
}
}
} catch (err) {
if (err.code !== 'ENOENT') throw err;
}
}
try {
await stat(SRC);
} catch {
console.error(`[sync-design-system] no source directory at ${SRC}`);
process.exit(1);
}
for (const dst of DSTS) {
await pruneStale(dst);
await copyTree(SRC, dst);
console.log(`[sync-design-system] mirrored ${SRC}${dst}`);
}

View File

@@ -0,0 +1,404 @@
#!/usr/bin/env node
/**
* Translate every English doc (src/content/docs/en/**) into one or more
* target locales using your own Ollama on neuronetz.ai.
*
* node scripts/translate-docs.mjs # all locales (de, ja, es, fr)
* node scripts/translate-docs.mjs --lang=de # only German
* node scripts/translate-docs.mjs --lang=de,ja # multi-select
* node scripts/translate-docs.mjs --force # overwrite existing
* node scripts/translate-docs.mjs --only=start/ # path prefix filter
*
* Env:
* OLLAMA_BASE_URL (default https://api.neuronetz.ai)
* OLLAMA_TRANSLATE_MODEL (default qwen3.6:35b → falls back to mistral-small,
* then qwen2.5-coder:14b)
*
* What it preserves verbatim:
* - YAML frontmatter STRUCTURE (only `title` + `description` get translated;
* keys, slugs, hero.actions[].link stay untouched)
* - Code fences (```…```) and inline code (`…`)
* - HTML/JSX tags including <CardGrid>, <Card title="…">
* - Markdown links + images
* - Internal anchor URLs (/en/… stays /en/… → not auto-rewritten;
* a separate pass rewrites the locale prefix in produced output.)
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DOCS_DIR = path.resolve(__dirname, '../src/content/docs');
const SOURCE_LANG = 'en';
const ALL_TARGETS = ['de', 'ja', 'es', 'fr'];
const OLLAMA_URL = (process.env.OLLAMA_BASE_URL ?? 'https://api.neuronetz.ai').replace(/\/$/, '');
// Default to a fast model that fits inside the nginx 60-90s timeout.
// qwen2.5-coder:14b is verified live; mistral-small handles European
// languages well; qwen2 is solid for Japanese.
// qwen2.5-coder:14b is conservative (low hallucination), proven on real
// pages. Slower than qwen2:7.6b but qwen2 hallucinated frontmatter and
// mixed scripts (Chinese chars in German output) — never again.
const PRIMARY_MODEL = process.env.OLLAMA_TRANSLATE_MODEL ?? 'qwen2.5-coder:14b';
const FALLBACK_MODELS = ['mistral-small:latest'];
const args = process.argv.slice(2);
const cli = {
langs: ALL_TARGETS,
force: false,
only: null,
};
for (const a of args) {
if (a.startsWith('--lang=')) cli.langs = a.slice(7).split(',').map((s) => s.trim()).filter(Boolean);
else if (a === '--force') cli.force = true;
else if (a.startsWith('--only=')) cli.only = a.slice(7);
}
const LANG_NAME = {
de: 'German', ja: 'Japanese', es: 'Spanish', fr: 'French',
};
// ---------------------------------------------------------------------------
// Markdown chunking — split into the smallest sensible units:
// - Each fenced code block is its own segment (kept verbatim).
// - Prose is split on blank-line paragraph boundaries.
// - Headings travel with their paragraph.
// Goal: every translatable chunk is ≤ ~300 tokens so the LLM completes
// in well under the nginx 60-90s window.
// ---------------------------------------------------------------------------
function splitForTranslate(body) {
const segments = [];
const lines = body.split('\n');
let buf = [];
let inFence = false;
let fenceMarker = '';
const flushProse = () => {
if (!buf.length) return;
// Split prose buffer at blank-line boundaries.
const text = buf.join('\n');
const paragraphs = text.split(/(\n\s*\n)/); // keep separators
let acc = '';
for (const part of paragraphs) {
if (/^\n\s*\n$/.test(part)) {
if (acc.trim()) segments.push({ kind: 'prose', text: acc });
else if (acc.length) segments.push({ kind: 'sep', text: acc });
segments.push({ kind: 'sep', text: part });
acc = '';
} else {
acc += part;
}
}
if (acc.length) {
segments.push({ kind: acc.trim() ? 'prose' : 'sep', text: acc });
}
buf = [];
};
const flushCode = () => {
if (!buf.length) return;
segments.push({ kind: 'code', text: buf.join('\n') });
buf = [];
};
for (const line of lines) {
const fenceOpen = line.match(/^([`~]{3,})/);
if (fenceOpen) {
if (!inFence) {
flushProse();
inFence = true;
fenceMarker = fenceOpen[1].slice(0, 3); // normalise length match
buf.push(line);
continue;
}
if (line.startsWith(fenceMarker)) {
buf.push(line);
flushCode();
inFence = false;
fenceMarker = '';
continue;
}
}
buf.push(line);
}
if (inFence) flushCode();
else flushProse();
return segments;
}
// ---------------------------------------------------------------------------
// Frontmatter parse + selective rewrite. Keep YAML keys verbatim; only
// translate the values of `title` and `description`.
// ---------------------------------------------------------------------------
function splitFrontmatter(raw) {
if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) {
return { fm: '', body: raw };
}
const closeIdx = raw.indexOf('\n---', 3);
if (closeIdx === -1) return { fm: '', body: raw };
const fm = raw.slice(0, closeIdx + 4);
const body = raw.slice(closeIdx + 4).replace(/^\r?\n/, '');
return { fm, body };
}
function rewriteFrontmatter(fm, translateString) {
if (!fm) return fm;
return fm
.split('\n')
.map((line) => {
const m = line.match(/^(\s*)(title|description):\s*(.*)$/);
if (!m) return line;
const [, indent, key, raw] = m;
const stripped = raw.replace(/^["']|["']$/g, '').trim();
if (!stripped) return line;
const tr = translateString(stripped);
// Always quote (handles colons, quotes safely)
const escaped = tr.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return `${indent}${key}: "${escaped}"`;
})
.join('\n');
}
// ---------------------------------------------------------------------------
// Ollama call with model fallback.
// ---------------------------------------------------------------------------
async function tryModels(messages, attempt = 0) {
const tryList = [PRIMARY_MODEL, ...FALLBACK_MODELS];
const model = tryList[attempt];
if (!model) throw new Error('All models failed.');
const res = await fetch(`${OLLAMA_URL}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages,
stream: false,
options: { temperature: 0.2, num_predict: 1024 },
}),
});
if (!res.ok) {
const text = await res.text();
if (res.status === 404 && attempt < tryList.length - 1) {
console.error(`${model} not found, trying ${tryList[attempt + 1]}`);
return tryModels(messages, attempt + 1);
}
throw new Error(`Ollama ${res.status}: ${text}`);
}
const data = await res.json();
return { text: data.message?.content ?? '', model };
}
const SYSTEM_PROMPT = (lang) => `You are a professional technical translator. Translate the user's text from English into ${LANG_NAME[lang]}.
CRITICAL: You MUST NOT invent, add, expand, summarise, or omit any content. The output must contain exactly the same number of paragraphs, list items and sentences as the input. If the input has 2 list items, the output has 2. Same count, same order.
ABSOLUTE PRESERVATION RULES (do NOT translate any of these):
- Anything between backticks: \`like_this\`, \`./command -flag\`, \`SomeClass::method()\`
- File paths: src/foo/bar.php, /core/modules/, application/view/
- URLs and URL fragments: https://…, /en/start/, #anchor-name
- CLI flags including their hyphens: -new-cms-page, -delete-cms-page, -mi, -m, --force, -c, -p
- Environment variable names: OLLAMA_BASE_URL, ANTHROPIC_API_KEY, APPLICATION_ENV
- Class/function/method names: pageAction, navigationAction, View::assign, Form::create
- Technical proper nouns: Nibiru, Smarty, Composer, PHP, MySQL, PostgreSQL, MMVC, MVC, CLI, ODBC, PDO, RAG, LoRA, Astro, Starlight, Ollama, Anthropic, Claude, OpenAI, GitHub, Elasticsearch, Graylog, JSON, YAML, INI, HTML, CSS, JS, TS, AJAX, SQL, AES.
- Code blocks (lines starting with \`\`\`) — copy verbatim
- HTML/JSX tags — copy structure verbatim, only translate visible text content
PRESERVE THE EXACT STRUCTURE:
- Headings keep their # / ## / ### level
- List bullet style (- or 1.) and indentation
- Bold (**), italic (*), strikethrough — keep the syntax around translated text
- Empty lines (paragraph breaks) — preserve every one
- Trailing newline / leading newline — preserve
OUTPUT FORMAT:
- Output the translated Markdown only. No preamble. No surrounding code fence. No "Here is the translation:" header. Start with the first character of the translation.
LANGUAGE STYLE:
- ja: 自然な技術日本語(です/ます調), no kana for translatable English words.
- fr: European French, formal "vous", proper accents (à, é, è, ê, ç, …).
- de: Formal "Sie" address. Standard German spelling.
- es: Neutral Latin-American Spanish, "tú" voice.`;
async function translateText(text, lang) {
if (!text.trim()) return text;
const { text: out } = await tryModels([
{ role: 'system', content: SYSTEM_PROMPT(lang) },
{ role: 'user', content: text },
]);
let cleaned = out;
// Models occasionally wrap the output in code fences — strip them.
const trimmedHead = cleaned.replace(/^\s*```[a-zA-Z]*\n?/, '');
if (trimmedHead !== cleaned) {
cleaned = trimmedHead.replace(/\n?```\s*$/, '');
}
// Restore the EXACT leading + trailing whitespace from the source so
// blank lines around the chunk (e.g. between a heading and a code fence)
// are preserved across translation.
const srcLead = (text.match(/^\s*/) || [''])[0];
const srcTrail = (text.match(/\s*$/) || [''])[0];
cleaned = srcLead + cleaned.replace(/^\s+|\s+$/g, '') + srcTrail;
return cleaned;
}
// Tiny synchronous shim for frontmatter — collect strings to translate, then
// translate them, then patch back. (Frontmatter has only 2 strings per file
// so we just translate them serially before rewriting.)
async function translateFrontmatter(fm, lang) {
if (!fm) return fm;
const matches = [...fm.matchAll(/^(\s*)(title|description):\s*(.+)$/gm)];
const cache = new Map();
for (const m of matches) {
const stripped = m[3].replace(/^["']|["']$/g, '').trim();
if (!stripped || cache.has(stripped)) continue;
try {
const tr = await translateText(stripped, lang);
// Single-line title sanity check — no 10x expansion, no CJK leak.
const issue = looksHallucinated(stripped, tr, lang);
if (issue) {
console.error(` ⚠ frontmatter "${stripped.slice(0, 40)}…" rejected (${issue}); keeping English.`);
cache.set(stripped, stripped);
} else {
cache.set(stripped, tr);
}
} catch (e) {
console.error(` ⚠ frontmatter "${stripped.slice(0, 40)}…" failed: ${e.message}`);
cache.set(stripped, stripped);
}
}
return rewriteFrontmatter(fm, (s) => cache.get(s) ?? s);
}
// Sanity check: a good translation has roughly the same number of newlines
// and Markdown structural markers as the source. If they diverge wildly
// we keep the original to avoid hallucinated padding.
function looksHallucinated(src, out, lang) {
if (!out.trim()) return 'empty output';
// 2.2x expansion ratio threshold
// 2.2x expansion is the hallucination-detection threshold for prose chunks,
// but short technical strings (e.g. "Auth" → "Authentifizierung") routinely
// expand 45×. So we use a soft floor: small inputs get an absolute budget.
const floorBudget = 80;
if (out.length > Math.max(floorBudget, src.length * 2.2)) {
return `expansion ${out.length}/${src.length}`;
}
// Same count of fenced code starts
const fences = (s) => (s.match(/^```/gm) || []).length;
if (fences(src) !== fences(out)) return 'fence count mismatch';
// Same count of list items
const bullets = (s) => (s.match(/^\s*([-*]|\d+\.) /gm) || []).length;
if (Math.abs(bullets(src) - bullets(out)) > 1) return 'list-item count mismatch';
// Same headings, AT THE SAME LEVELS
const headings = (s) => (s.match(/^(#{1,6}) /gm) || []).map((h) => h.trim().length);
const hSrc = headings(src);
const hOut = headings(out);
if (hSrc.length !== hOut.length) return 'heading count mismatch';
for (let i = 0; i < hSrc.length; i++) {
if (hSrc[i] !== hOut[i]) return `heading level mismatch at #${i + 1}: ${hSrc[i]}${hOut[i]}`;
}
// Script mixing: don't allow CJK characters in non-CJK target output.
if (lang !== 'ja') {
if (/[぀-ヿ㐀-䶿一-鿿]/.test(out)) return 'CJK chars leaked into non-CJK output';
}
// Frontmatter title length sanity (when src is a single-line title): no
// 10x expansion. This guards the "Spanish title hallucinated paragraph" case.
if (!src.includes('\n') && out.length > Math.max(80, src.length * 3)) {
return `single-line ${out.length}/${src.length} expansion`;
}
return null;
}
async function translateBody(body, lang) {
const segs = splitForTranslate(body);
const out = [];
let proseChunks = 0;
for (const seg of segs) {
if (seg.kind === 'code' || seg.kind === 'sep') {
out.push(seg.text);
continue;
}
proseChunks++;
try {
const tr = await translateText(seg.text, lang);
const issue = looksHallucinated(seg.text, tr, lang);
if (issue) {
console.error(` ⚠ prose chunk ${proseChunks} rejected (${issue}); keeping English.`);
out.push(seg.text);
} else {
out.push(tr);
}
} catch (e) {
const msg = String(e.message).slice(0, 120);
console.error(` ⚠ prose chunk ${proseChunks} failed: ${msg}; keeping English.`);
out.push(seg.text);
}
}
return out.join('');
}
function relPath(file) {
return path.relative(DOCS_DIR, file).replace(/\\/g, '/');
}
function targetPath(srcPath, lang) {
const rel = relPath(srcPath);
const stripped = rel.replace(/^en\//, `${lang}/`);
return path.join(DOCS_DIR, stripped);
}
function walkEnglish() {
const root = path.join(DOCS_DIR, SOURCE_LANG);
const out = [];
const stack = [root];
while (stack.length) {
const d = stack.pop();
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, entry.name);
if (entry.isDirectory()) stack.push(p);
else if (/\.(md|mdx)$/.test(entry.name)) out.push(p);
}
}
return out.sort();
}
async function main() {
console.log(`Ollama: ${OLLAMA_URL}`);
console.log(`Primary model: ${PRIMARY_MODEL}`);
console.log(`Languages: ${cli.langs.join(', ')}`);
if (cli.only) console.log(`Only: ${cli.only}`);
if (cli.force) console.log('Force overwrite: yes');
const files = walkEnglish().filter((f) => {
if (!cli.only) return true;
return relPath(f).replace(/^en\//, '').startsWith(cli.only);
});
console.log(`\nFound ${files.length} English source files.\n`);
for (const lang of cli.langs) {
console.log(`\n=== ${LANG_NAME[lang]} (${lang}) ===`);
for (const src of files) {
const dst = targetPath(src, lang);
if (fs.existsSync(dst) && !cli.force) {
console.log(` · ${relPath(src)}${path.relative(DOCS_DIR, dst)} (exists, skip)`);
continue;
}
console.log(`${relPath(src)}`);
const raw = fs.readFileSync(src, 'utf8');
const { fm, body } = splitFrontmatter(raw);
const newFm = await translateFrontmatter(fm, lang);
const newBody = await translateBody(body, lang);
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.writeFileSync(dst, newFm + '\n' + newBody);
console.log(`${path.relative(DOCS_DIR, dst)}`);
}
}
console.log('\nDone.');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<!-- Lotus mark drawn from the actual brand logo -->
<!-- Center petal (deepest violet, vertical) -->
<path d="M32 8 C 28 22, 28 38, 32 50 C 36 38, 36 22, 32 8 Z" fill="#5e548c" opacity="0.85"/>
<!-- Inner-left violet petal -->
<path d="M22 14 C 16 24, 18 38, 28 46 C 30 36, 28 22, 22 14 Z" fill="#7c70ab" opacity="0.85"/>
<!-- Inner-right violet petal -->
<path d="M42 14 C 48 24, 46 38, 36 46 C 34 36, 36 22, 42 14 Z" fill="#7c70ab" opacity="0.85"/>
<!-- Outer-left blue petal -->
<path d="M10 22 C 6 32, 12 44, 24 48 C 22 38, 18 26, 10 22 Z" fill="#7db7dc" opacity="0.85"/>
<!-- Outer-right blue petal -->
<path d="M54 22 C 58 32, 52 44, 40 48 C 42 38, 46 26, 54 22 Z" fill="#7db7dc" opacity="0.85"/>
<!-- Far-left blue petal -->
<path d="M2 28 C 4 38, 14 46, 22 46 C 18 38, 10 30, 2 28 Z" fill="#a4cae0" opacity="0.7"/>
<!-- Far-right blue petal -->
<path d="M62 28 C 60 38, 50 46, 42 46 C 46 38, 54 30, 62 28 Z" fill="#a4cae0" opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,250 @@
---
import Default from '@astrojs/starlight/components/Header.astro';
import Search from 'virtual:starlight/components/Search';
/**
* Three branches:
* - Splash pages → custom marketing <header class="nav">
* - Docs pages (rest) → mockup's <header class="doc-header"> with
* brand-mark, primary doc-nav, search-inline,
* lang-switcher pills (per docs-page-mockup.html)
* - Anything weird → Starlight Default (fallback safety net)
*/
const route = Astro.locals.starlightRoute;
const isSplash = route?.entry?.data?.template === 'splash';
const isDocs = !!route?.entry && !isSplash;
const currentLocale = route?.locale ?? 'en';
const localeLabels = { en: 'EN', de: 'DE', ja: 'JA', es: 'ES', fr: 'FR' } as const;
const supportedLocales = Object.keys(localeLabels) as Array<keyof typeof localeLabels>;
// Swap the leading /xx/ segment of the current path to switch language.
const currentPath = Astro.url.pathname;
function localizedPath(target: string): string {
if (currentPath.startsWith(`/${currentLocale}/`)) {
return currentPath.replace(`/${currentLocale}/`, `/${target}/`);
}
return `/${target}/`;
}
// The five primary nav targets — kept in sync with the splash header.
const docNav: Array<{ label: string; href: string; current?: boolean }> = [
{ label: 'Docs', href: `/${currentLocale}/start/installation/` },
{ label: 'MMVC', href: `/${currentLocale}/core/architecture/` },
{ label: 'AI module',href: `/${currentLocale}/ai/module/overview/` },
{ label: 'CLI', href: `/${currentLocale}/start/quick-start/` },
{ label: 'Showcase', href: `/${currentLocale}/showcase/` },
];
---
{isSplash ? (
<header class="nav" id="nav">
<div class="container nav-row">
<a class="brand" href="/en/" aria-label="Nibiru — home">
<img class="brand-mark nav-logo" src="/img/nibiru-lotus.png" alt="" width="60" height="36" />
<span class="brand-name">Nibiru</span>
<span class="nav-version"><span class="dot"></span>v0.9.2</span>
</a>
<nav class="nav-links" aria-label="Primary">
<a href="/en/core/architecture/">MMVC</a>
<a href="/en/ai/oracle/">Mission Control</a>
<a href="/en/start/quick-start/">Code</a>
<a href="/en/start/installation/">Install</a>
<a href="/en/start/what-is-nibiru/">Docs</a>
</nav>
<div class="nav-cta">
<a class="btn btn-ghost btn-icon-only" href="https://github.com/alllinux/Nibiru" aria-label="GitHub">
<svg class="ico" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38v-1.36c-2.23.48-2.7-1.07-2.7-1.07-.36-.93-.89-1.18-.89-1.18-.73-.5.05-.49.05-.49.81.06 1.24.83 1.24.83.72 1.24 1.89.88 2.35.67.07-.52.28-.88.51-1.08-1.78-.2-3.65-.89-3.65-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.13 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.11.16 1.93.08 2.13.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48v2.2c0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
</svg>
</a>
<a class="btn btn-solid" href="#download">Download</a>
</div>
</div>
</header>
) : isDocs ? (
<header class="doc-header">
<div class="doc-header-row">
<a class="brand" href={`/${currentLocale}/`} aria-label="Nibiru — home">
<img class="brand-mark" src="/img/nibiru-lotus.png" alt="" width="50" height="30" />
<span class="brand-name">Nibiru<em> docs</em></span>
<span class="nav-version"><span class="dot"></span>v0.9.2</span>
</a>
<nav class="doc-nav" aria-label="Primary">
{docNav.map((item) => (
<a href={item.href} aria-current={Astro.url.pathname.startsWith(item.href) ? 'page' : undefined}>{item.label}</a>
))}
</nav>
<div class="doc-header-tools">
<Search />
<div class="lang-switcher" aria-label="Language">
{supportedLocales.map((loc) => (
<a href={localizedPath(loc)} aria-current={loc === currentLocale ? 'true' : undefined}>{localeLabels[loc]}</a>
))}
</div>
</div>
</div>
</header>
) : (
<Default {...Astro.props}>
<slot />
</Default>
)}
<script is:inline>
(function () {
// The mockup's nibiru-scene.js owns nav-condense on splash pages.
// For non-splash pages we still tag the document so the docs nav can
// shrink the same way.
if (document.querySelector('header.nav')) return;
const root = document.documentElement;
const onScroll = function () {
if (window.scrollY > 24) root.setAttribute('data-nav-condensed', '');
else root.removeAttribute('data-nav-condensed');
};
window.addEventListener('scroll', onScroll, { passive: true });
onScroll();
})();
(function () {
// The Oracle launcher must live at document.body level — not inside <header>.
// The header has `backdrop-filter` which makes it a containing block for
// position:fixed descendants, so a button rendered inside it would anchor
// to the header (top-right) instead of the viewport (bottom-right).
if (document.getElementById('oracle-launcher')) return;
const launcher = document.createElement('button');
launcher.id = 'oracle-launcher';
launcher.type = 'button';
launcher.setAttribute('aria-label', 'Open Nibiru Oracle (AI assistant)');
launcher.title = 'Ask the Oracle';
const panel = document.createElement('aside');
panel.id = 'oracle-panel';
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', 'Nibiru Oracle');
panel.innerHTML = `
<header>
<div>
<h3>Nibiru Oracle</h3>
<div class="oracle-subtitle">Trained on the framework itself</div>
</div>
<button class="oracle-close" type="button" aria-label="Close">✕</button>
</header>
<div id="oracle-messages">
<div class="oracle-msg system">
Ask anything about Nibiru — routing, modules, the CLI, real-world examples.
</div>
</div>
<form id="oracle-form" autocomplete="off">
<textarea name="q" placeholder="How do I create a new module?" rows="1" required></textarea>
<button type="submit">Send</button>
</form>
`;
document.body.appendChild(launcher);
document.body.appendChild(panel);
const closeBtn = panel.querySelector('.oracle-close');
const form = panel.querySelector('#oracle-form');
const input = form.querySelector('textarea');
const submitBtn = form.querySelector('button[type="submit"]');
const list = panel.querySelector('#oracle-messages');
const history = [];
function track(event, data) {
if (typeof window.umami?.track === 'function') {
try { window.umami.track(event, data || {}); } catch (e) {}
}
}
function open() {
panel.classList.add('is-open');
setTimeout(() => input.focus(), 80);
track('oracle-open');
}
function close() { panel.classList.remove('is-open'); }
launcher.addEventListener('click', () =>
panel.classList.contains('is-open') ? close() : open()
);
closeBtn.addEventListener('click', close);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
function renderMarkdown(md) {
md = md.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
md = md.replace(/```([a-z]*)\n([\s\S]*?)```/g, (_, lang, code) =>
`<pre><code class="lang-${lang}">${code}</code></pre>`);
md = md.replace(/`([^`\n]+?)`/g, '<code>$1</code>');
md = md.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
md = md.replace(/\*([^*]+)\*/g, '<em>$1</em>');
md = md.replace(/\n\n+/g, '</p><p>');
md = md.replace(/\n/g, '<br/>');
return '<p>' + md + '</p>';
}
function append(role, text, sources) {
const el = document.createElement('div');
el.className = 'oracle-msg ' + role;
if (role === 'assistant') {
el.innerHTML = renderMarkdown(text);
if (sources && sources.length) {
const cites = document.createElement('div');
cites.style.marginTop = '0.55rem';
cites.style.fontSize = '0.75rem';
cites.innerHTML = '<span style="opacity:0.7">Sources: </span>' +
sources.map((s, i) => `<a class="oracle-cite" href="${s.url}">${i + 1}. ${s.title}</a>`).join(' ');
el.appendChild(cites);
}
} else {
el.textContent = text;
}
list.appendChild(el);
list.scrollTop = list.scrollHeight;
return el;
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const q = input.value.trim();
if (!q) return;
append('user', q);
history.push({ role: 'user', content: q });
input.value = '';
submitBtn.disabled = true;
track('oracle-question', {
lang: (document.documentElement.lang || 'en').slice(0, 2),
turn: history.filter((m) => m.role === 'user').length,
});
const placeholder = append('assistant', 'Consulting the docs…');
try {
const res = await fetch('/api/oracle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: history }),
});
if (!res.ok) {
const t = await res.text();
placeholder.innerHTML = renderMarkdown(`The Oracle is silent: ${t || res.statusText}`);
return;
}
const data = await res.json();
placeholder.remove();
append('assistant', data.answer || '(no response)', data.sources || []);
history.push({ role: 'assistant', content: data.answer });
} catch (err) {
placeholder.innerHTML = renderMarkdown(`Connection lost: ${err.message}`);
} finally {
submitBtn.disabled = false;
input.focus();
}
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
});
})();
</script>

View File

@@ -0,0 +1,55 @@
---
const { data } = Astro.locals.starlightRoute.entry;
const hero = data.hero;
const title = hero?.title ?? data.title ?? '';
const tagline = hero?.tagline ?? data.description ?? '';
const eyebrow = hero?.eyebrow ?? 'Modular MMVC PHP framework';
const actions = Array.isArray(hero?.actions) ? hero.actions : [];
const shouldRender = !!hero;
---
{shouldRender && (
<section class="atelier-hero">
<span class="atelier-hero__number" aria-hidden="true">01</span>
<div class="atelier-hero__grid">
<div class="atelier-hero__copy">
{eyebrow && <p class="atelier-hero__eyebrow">{eyebrow}</p>}
{title && <h1 class="atelier-hero__title" set:html={title} />}
{tagline && <p class="atelier-hero__lede" set:html={tagline} />}
{actions.length > 0 && (
<div class="atelier-hero__cta">
{actions.map((a: any) => (
<a
href={a.link}
class={`atelier-button ${
a.variant === 'minimal' || a.variant === 'secondary'
? 'atelier-button--ghost'
: 'atelier-button--primary'
}`}
>
<span>{a.text}</span>
{a.icon === 'right-arrow' && <span class="atelier-button__arrow" aria-hidden="true">→</span>}
{a.icon === 'external' && <span class="atelier-button__arrow" aria-hidden="true">↗</span>}
</a>
))}
</div>
)}
</div>
<div class="atelier-hero__art" aria-hidden="true">
<img
class="atelier-hero__mark"
src="/img/nibiru-logo.png"
alt=""
loading="eager"
width="280"
height="280"
/>
</div>
</div>
</section>
)}

View File

@@ -0,0 +1,53 @@
---
/**
* CodeCard — dark cosmic terminal pane (v3 design system).
*
* <CodeCard lang="bash" meta="bash · 5 lines" code={`...`} />
*
* The `code` prop is dumped raw via set:html so MDX never sees its contents,
* which means template-string newlines (including blank lines) and HTML token
* spans (`<span class="tk-k">`, `<span class="tk-s">` …) come through intact.
*
* • Use \\u00a0 / &nbsp; if you need preserved whitespace.
* • Wrap keywords with <span class="tk-k">…</span>, strings with tk-s, etc.
*
* Tabs is an array — first entry is the active one. `meta` is the right-hand
* caption ("php · 9 lines"). Line numbers in the gutter are auto-derived from
* the `code` newline count.
*/
interface Props {
tabs?: string[];
lang?: string;
meta?: string;
code: string;
startLine?: number;
}
const {
tabs,
lang = 'bash',
meta,
code,
startLine = 1,
} = Astro.props as Props;
const tabLabels = tabs && tabs.length ? tabs : [lang];
const lineCount = code.split('\n').length;
const gutter = Array.from({ length: lineCount }, (_, i) => `${startLine + i}`.padStart(2, ' ')).join('\n');
const metaLabel = meta ?? `${lang} · ${lineCount} line${lineCount === 1 ? '' : 's'}`;
---
<div class="code-card">
<div class="code-card__head">
<div class="code-card__tabs">
{tabLabels.map((label, i) => (
<span class={`code-card__tab${i === 0 ? ' is-active' : ''}`}>{label}</span>
))}
</div>
<div class="code-card__meta">{metaLabel}</div>
</div>
<div class="code-card__body">
<pre class="code-card__gutter" aria-hidden="true">{gutter}</pre>
<pre class="code-card__pre" set:html={code} />
</div>
</div>

View File

@@ -0,0 +1,21 @@
---
/**
* CometTrail — pointer-following particle trail rendered as a fixed canvas
* overlay. Pure vanilla canvas2d, additive blending so the trail glows.
* Skipped on touch devices and under prefers-reduced-motion.
*/
---
<canvas id="comet-trail" aria-hidden="true"></canvas>
<style is:global>
#comet-trail {
position: fixed; inset: 0;
pointer-events: none;
z-index: 100;
width: 100%; height: 100%;
}
@media (prefers-reduced-motion: reduce), (pointer: coarse) {
#comet-trail { display: none; }
}
</style>

View File

@@ -0,0 +1,298 @@
---
/**
* EditorialContent — the prose tail of the splash. Lives between Spacecraft
* and the footer; carries the production-scale evidence and the entry-point
* doc links so visitors who scroll past the demo end with a clear next step.
*
* 05 — In production (Maschinen-Stockert showcase + scale quote)
* 06 — Pick a thread (4 link cards into the docs)
* 07 — Marquee close
*/
---
{/* ============================================================
05 · IN PRODUCTION
============================================================ */}
<section class="section" id="production" aria-labelledby="prod-h">
<div class="container">
<div class="section-head">
<span class="eyebrow">In production · 05</span>
<h2 id="prod-h" class="section-title">Real apps. <span class="accent">Real revenue.</span></h2>
<p class="section-sub">
The flagship Nibiru deployment is the <strong>Maschinen Stockert</strong> group — two
repos powering one of Austria's larger industrial-machinery e-commerce platforms.
18 shared modules, ~74 000 lines of PHP, 161 timestamped SQL migrations into
production. No service container.
</p>
</div>
<div class="prod-grid">
<a class="prod-card" href="/en/showcase/projects/#maschinen-stockertde--public-catalogue">
<div class="prod-card__head">
<span class="prod-card__tag">Public catalogue</span>
<span class="prod-card__url">maschinen-stockert.de</span>
</div>
<p class="prod-card__desc">
Multilingual content, Elasticsearch search, SEO-friendly URLs, Yumpu PDF flipbooks.
</p>
<div class="prod-card__stats">
<div><span class="v">10</span><span class="l">Controllers</span></div>
<div><span class="v">18</span><span class="l">Modules</span></div>
<div><span class="v">150</span><span class="l">Templates</span></div>
<div><span class="v">36 289</span><span class="l">Lines of PHP</span></div>
</div>
<span class="prod-card__cta">Read the case <span class="arrow">→</span></span>
</a>
<a class="prod-card" href="/en/showcase/projects/#datamaschinen-stockertde--admin--api">
<div class="prod-card__head">
<span class="prod-card__tag">Admin &amp; API</span>
<span class="prod-card__url">data.maschinen-stockert.de</span>
</div>
<p class="prod-card__desc">
Page-tree CMS, role-based ACL, public-API whitelist, Machineryscout indexer.
</p>
<div class="prod-card__stats">
<div><span class="v">36</span><span class="l">Controllers</span></div>
<div><span class="v">18</span><span class="l">Modules</span></div>
<div><span class="v">348</span><span class="l">Templates</span></div>
<div><span class="v">37 369</span><span class="l">Lines of PHP</span></div>
</div>
<span class="prod-card__cta">Read the case <span class="arrow">→</span></span>
</a>
</div>
<blockquote class="prod-quote">
Two repos, 18 shared modules, 161 timestamped SQL migrations into production with
no migration framework, 74 000 lines of PHP with no service container. The team
that built and runs it is small.
</blockquote>
</div>
</section>
{/* ============================================================
06 · PICK A THREAD
============================================================ */}
<section class="section" id="threads" aria-labelledby="threads-h">
<div class="container">
<div class="section-head">
<span class="eyebrow">Where next · 06</span>
<h2 id="threads-h" class="section-title">Pick a <span class="accent">thread.</span></h2>
<p class="section-sub">
Four ways into the framework. None of them takes more than five minutes to start.
</p>
</div>
<div class="thread-grid">
<a class="thread-card" href="/en/start/what-is-nibiru/">
<span class="thread-card__num">01</span>
<h3 class="thread-card__title">What is Nibiru?</h3>
<p class="thread-card__desc">The 90-second tour: MMVC, the dispatcher, the request lifecycle.</p>
<span class="thread-card__cta">Read <span class="arrow">→</span></span>
</a>
<a class="thread-card" href="/en/why-nibiru/">
<span class="thread-card__num">02</span>
<h3 class="thread-card__title">Why Nibiru, not Laravel</h3>
<p class="thread-card__desc">Five things Nibiru does differently — each backed by real production code.</p>
<span class="thread-card__cta">Read <span class="arrow">→</span></span>
</a>
<a class="thread-card" href="/en/start/local-testing/">
<span class="thread-card__num">03</span>
<h3 class="thread-card__title">Run it locally</h3>
<p class="thread-card__desc">Three paths from clone to running site, including the Oracle on your own Ollama.</p>
<span class="thread-card__cta">Read <span class="arrow">→</span></span>
</a>
<a class="thread-card" href="/en/ai/module/overview/">
<span class="thread-card__num">04</span>
<h3 class="thread-card__title">The AI module</h3>
<p class="thread-card__desc">Chat, embeddings, RAG, agents — first-class AI in your Nibiru app.</p>
<span class="thread-card__cta">Read <span class="arrow">→</span></span>
</a>
</div>
</div>
</section>
{/* ============================================================
07 · MARQUEE CLOSE
============================================================ */}
<section class="section section--marquee" aria-hidden="true">
<p class="atelier-marquee">Create · Invent · Impress</p>
</section>
<style is:global>
/* === Production showcase (Maschinen-Stockert plates) === */
.prod-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
gap: 1.25rem;
max-width: var(--nibiru-container, 1280px);
margin: 0 auto;
padding: 0 32px;
}
.prod-card {
display: flex; flex-direction: column; gap: 1rem;
background: linear-gradient(180deg, var(--nibiru-night), var(--nibiru-space));
border: 1px solid var(--nibiru-line);
border-radius: var(--nibiru-radius-2xl);
padding: 1.4rem 1.5rem;
color: var(--nibiru-star);
text-decoration: none;
transition: transform 240ms var(--nibiru-ease-out, ease),
border-color 240ms ease,
box-shadow 240ms ease;
}
.prod-card:hover {
transform: translateY(-2px);
border-color: var(--nibiru-nebula-mag);
box-shadow: var(--nibiru-shadow-lg);
}
.prod-card__head {
display: flex; align-items: baseline; justify-content: space-between;
gap: 1rem; flex-wrap: wrap;
}
.prod-card__tag {
font-family: var(--font-mono);
font-size: 0.69rem;
letter-spacing: 0.10em;
text-transform: uppercase;
color: var(--nibiru-nebula-mag);
}
.prod-card__url {
font-family: var(--font-mono);
font-size: 0.78rem;
color: var(--nibiru-muted);
}
.prod-card__desc {
margin: 0;
color: rgba(244, 238, 219, 0.75);
font-size: 0.95rem;
line-height: 1.55;
}
.prod-card__stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
padding: 0.85rem 0;
border-top: 1px solid var(--nibiru-line);
border-bottom: 1px solid var(--nibiru-line);
}
.prod-card__stats div { display: flex; flex-direction: column; gap: 2px; }
.prod-card__stats .v {
font-family: var(--font-display);
font-size: 1rem; font-weight: 500;
color: var(--nibiru-star);
letter-spacing: -0.02em;
}
.prod-card__stats .l {
font-family: var(--font-mono);
font-size: 0.62rem;
color: var(--nibiru-muted);
letter-spacing: 0.10em;
text-transform: uppercase;
}
.prod-card__cta {
margin-top: auto;
font-size: 0.92rem;
color: var(--nibiru-star);
display: inline-flex; align-items: center; gap: 8px;
}
.prod-card__cta .arrow {
font-family: var(--font-mono);
transition: transform 200ms ease;
}
.prod-card:hover .prod-card__cta .arrow { transform: translateX(4px); }
.prod-quote {
max-width: 56rem;
margin: 4rem auto 0;
padding: 0 32px;
font-family: var(--font-display);
font-size: clamp(1.15rem, 1rem + 0.6vw, 1.45rem);
line-height: 1.55;
letter-spacing: -0.015em;
color: var(--nibiru-star);
border-left: 3px solid var(--nibiru-nebula-mag);
padding-left: 1.4rem;
}
@media (max-width: 720px) {
.prod-card__stats { grid-template-columns: repeat(2, 1fr); }
}
/* === Pick-a-thread cards === */
.thread-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
gap: 1rem;
max-width: var(--nibiru-container, 1280px);
margin: 0 auto;
padding: 0 32px;
}
.thread-card {
position: relative;
display: flex; flex-direction: column; gap: 0.7rem;
padding: 1.5rem 1.4rem 1.3rem;
background: var(--nibiru-night);
border: 1px solid var(--nibiru-line);
border-radius: var(--nibiru-radius-xl);
color: var(--nibiru-star);
text-decoration: none;
overflow: hidden;
transition: transform 240ms ease, border-color 240ms ease, background 240ms ease;
}
.thread-card::before {
content: '';
position: absolute; inset: 0;
background: radial-gradient(ellipse 80% 60% at 100% 0%, rgba(184, 107, 255, 0.18), transparent 70%);
opacity: 0;
transition: opacity 240ms ease;
pointer-events: none;
}
.thread-card:hover {
transform: translateY(-2px);
border-color: var(--nibiru-nebula-mag);
background: rgba(28, 15, 58, 0.6);
}
.thread-card:hover::before { opacity: 1; }
.thread-card__num {
font-family: var(--font-mono);
font-size: 0.69rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--nibiru-nebula-mag);
}
.thread-card__title {
margin: 0;
font-family: var(--font-display);
font-size: 1.15rem;
font-weight: 500;
letter-spacing: -0.02em;
color: var(--nibiru-star);
}
.thread-card__desc {
margin: 0;
flex: 1;
font-size: 0.92rem;
line-height: 1.55;
color: rgba(244, 238, 219, 0.7);
}
.thread-card__cta {
font-size: 0.86rem;
color: rgba(244, 238, 219, 0.85);
display: inline-flex; align-items: center; gap: 8px;
margin-top: 0.4rem;
}
.thread-card__cta .arrow {
font-family: var(--font-mono);
transition: transform 200ms ease;
}
.thread-card:hover .thread-card__cta .arrow { transform: translateX(4px); }
.thread-card:hover .thread-card__cta { color: var(--nibiru-nebula-mag); }
/* === Marquee close === */
.section--marquee {
padding: 6rem 0 7rem;
}
.section--marquee .atelier-marquee {
text-align: center;
margin: 0;
}
</style>

View File

@@ -0,0 +1,33 @@
---
/**
* Footer override — keeps Starlight's Pagination, drops the duplicate
* edit/last-updated meta (already shown in PageTitle's .doc-meta), and
* appends the docs-page-mockup .help-strip ("Was this page helpful?").
*
* Styles live in src/styles/starlight-docs-bridge.css §09 (.help-strip).
*/
import Pagination from 'virtual:starlight/components/Pagination';
const route = Astro.locals.starlightRoute;
const editUrl = route.editUrl?.toString();
---
<footer class="sl-flex">
<Pagination />
<aside class="help-strip">
<span class="help-strip-text">Was this page helpful?</span>
<div class="help-strip-actions">
<a href={`https://github.com/alllinux/Nibiru/discussions/new?category=docs-feedback&title=${encodeURIComponent('Feedback: ' + (route.entry.data.title ?? ''))}`}>Yes</a>
{editUrl && <a href={editUrl}>Suggest an edit</a>}
</div>
</aside>
</footer>
<style>
@layer starlight.core {
footer {
flex-direction: column;
gap: 1.5rem;
}
}
</style>

View File

@@ -0,0 +1,218 @@
---
/**
* GalaxyHero — splash hero, v4 "Cosmos" dialect.
*
* Full-viewport dark cosmic stage with a vanilla-canvas2d constellation,
* gradient-text headline, hero CTA links and a telemetry strip at the
* bottom edge. No three.js, no WebGL — works on every browser, respects
* prefers-reduced-motion.
*
* Hero contract (matches the Atelier hero):
* data.hero.eyebrow → eyebrow row above the headline
* data.hero.title → display headline (HTML allowed; wrap accent words in <em>, end with .period span)
* data.hero.tagline → sub copy (HTML allowed)
* data.hero.actions → CTA links (variant: primary | secondary | minimal)
*/
const { data } = Astro.locals.starlightRoute.entry;
const hero = data.hero;
const title = hero?.title ?? data.title ?? '';
const tagline = hero?.tagline ?? data.description ?? '';
const eyebrow = hero?.eyebrow ?? 'Open source · PHP 8 · MMVC';
const actions = Array.isArray(hero?.actions) ? hero.actions : [];
const shouldRender = !!hero;
---
{shouldRender && (
<section class="hero" aria-labelledby="hero-h">
<canvas id="constellation" aria-hidden="true"></canvas>
<div class="container hero-inner">
{eyebrow && (
<div class="hero-meta">
<span class="eyebrow">{eyebrow}</span>
</div>
)}
{title && (
<h1 id="hero-h" class="hero-headline" set:html={title} />
)}
{tagline && (
<p class="hero-sub" set:html={tagline} />
)}
{actions.length > 0 && (
<div class="hero-ctas">
{actions.map((a: any, i: number) => (
<a
href={a.link}
class={`hero-link${i === 0 ? '' : ' muted'}`}
>
{a.text} <span class="arrow" aria-hidden="true">→</span>
</a>
))}
</div>
)}
</div>
<div class="hero-telemetry" aria-hidden="true">
<div class="tele-cell"><span class="pulse"></span> <strong>NIBIRU SYSTEM</strong> · live</div>
<div class="tele-cell">FRAMEWORK <strong>v0.9.2</strong></div>
<div class="tele-cell">MODULES <strong>4 in orbit</strong></div>
<div class="tele-cell">CONTROLLER <strong>idle</strong></div>
<div class="tele-cell">MIT</div>
</div>
{/* Tooltip placeholder for hero module-hover labels — positioned by nibiru-scene.js */}
<div class="module-tip" id="moduleTip" role="tooltip" aria-hidden="true">
<span class="name"></span>
<span class="sig"></span>
</div>
</section>
)}
<style is:global>
.hero {
position: relative;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
isolation: isolate;
/* Wider than the docs content — full viewport bleed */
width: 100vw;
margin-left: 50%;
margin-right: 50%;
transform: translateX(-50%);
background: var(--nibiru-gradient-nebula);
color: var(--nibiru-star);
}
#constellation {
position: absolute; inset: 0;
width: 100%; height: 100%;
z-index: 0;
}
/* Bottom fade so the headline reads cleanly against the field below */
.hero::after {
content: '';
position: absolute; left: 0; right: 0; bottom: 0; height: 50%;
background: linear-gradient(to top, var(--nibiru-space) 0%, rgba(10, 4, 20, 0.7) 30%, transparent 100%);
pointer-events: none;
z-index: 1;
}
.hero-inner {
position: relative; z-index: 2;
max-width: var(--nibiru-container, 1280px);
margin: 0 auto;
padding: 220px 32px 140px;
width: 100%;
}
.hero-meta {
display: flex;
gap: 24px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 36px;
}
.hero-headline {
font-family: var(--font-display);
font-size: var(--nibiru-text-hero, clamp(3.5rem, 7vw + 0.5rem, 7.75rem));
line-height: 0.95;
letter-spacing: var(--nibiru-tracking-display, -0.04em);
font-weight: 400;
margin: 0;
max-width: 14ch;
color: var(--nibiru-star);
}
.hero-headline em {
font-style: normal;
font-weight: 500;
background: var(--nibiru-gradient-headline);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
.hero-headline .period {
color: var(--nibiru-nebula-amber);
font-weight: 500;
}
.hero-sub {
margin: 40px 0 0;
font-size: 1.125rem;
color: rgba(244, 238, 219, 0.72);
max-width: 48ch;
line-height: 1.5;
}
.hero-ctas {
display: flex;
gap: 28px;
align-items: center;
margin-top: 48px;
flex-wrap: wrap;
}
.hero-link {
display: inline-flex;
align-items: center;
gap: 12px;
color: var(--nibiru-star);
font-size: 1rem;
padding: 4px 0;
border-bottom: 1px solid rgba(244, 238, 219, 0.4);
text-decoration: none;
transition: gap 200ms ease, border-color 200ms ease, color 200ms ease;
}
.hero-link:hover {
gap: 18px;
border-bottom-color: var(--nibiru-star);
}
.hero-link .arrow {
font-family: var(--font-mono);
transition: transform 200ms ease;
}
.hero-link:hover .arrow { transform: translateX(4px); }
.hero-link.muted {
color: rgba(244, 238, 219, 0.6);
border-bottom-color: rgba(244, 238, 219, 0.18);
}
.hero-link.muted:hover { color: var(--nibiru-star); }
.hero-telemetry {
position: absolute;
bottom: 32px; left: 0; right: 0;
z-index: 3;
display: flex;
gap: 48px;
justify-content: space-between;
padding: 0 32px;
font-family: var(--font-mono);
font-size: 0.69rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--nibiru-muted);
flex-wrap: wrap;
}
.hero-telemetry .tele-cell strong {
color: rgba(244, 238, 219, 0.85);
font-weight: 500;
}
.hero-telemetry .tele-cell .pulse {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--nibiru-success, #7ad6a3);
box-shadow: 0 0 8px rgba(122, 214, 163, 0.7);
animation: tele-pulse 2.4s ease-in-out infinite;
}
@media (max-width: 960px) {
.hero-headline { font-size: clamp(3rem, 12vw, 5rem); }
.hero-telemetry { display: none; }
.hero-inner { padding: 160px 24px 100px; }
}
@media (prefers-reduced-motion: reduce) {
#constellation { display: none; }
}
</style>

View File

@@ -0,0 +1,563 @@
---
const { data } = Astro.locals.starlightRoute.entry;
const hero = data.hero;
const title = hero?.title ?? data.title ?? '';
const tagline = hero?.tagline ?? data.description ?? '';
const eyebrow = hero?.eyebrow ?? 'Modular MMVC PHP framework';
const actions = Array.isArray(hero?.actions) ? hero.actions : [];
const shouldRender = !!hero;
---
{shouldRender && (
<section class="galaxy-hero">
<canvas class="galaxy-hero__canvas" id="galaxy-canvas" aria-hidden="true"></canvas>
<div class="galaxy-hero__veil" aria-hidden="true"></div>
<span class="galaxy-hero__number" aria-hidden="true">01</span>
<div class="galaxy-hero__copy">
{eyebrow && <p class="galaxy-hero__eyebrow">{eyebrow}</p>}
{title && <h1 class="galaxy-hero__title" set:html={title} />}
{tagline && <p class="galaxy-hero__lede" set:html={tagline} />}
{actions.length > 0 && (
<div class="galaxy-hero__cta">
{actions.map((a: any) => (
<a
href={a.link}
class={`galaxy-button ${
a.variant === 'minimal' || a.variant === 'secondary'
? 'galaxy-button--ghost'
: 'galaxy-button--primary'
}`}
>
<span>{a.text}</span>
{a.icon === 'right-arrow' && <span class="galaxy-button__arrow" aria-hidden="true">→</span>}
{a.icon === 'external' && <span class="galaxy-button__arrow" aria-hidden="true">↗</span>}
</a>
))}
</div>
)}
</div>
</section>
)}
<style is:global>
/* === HERO container — FULL-BLEED, framed, centered === */
.galaxy-hero {
position: relative;
isolation: isolate;
width: 100vw;
margin-left: 50%;
margin-right: 50%;
transform: translateX(-50%);
padding: clamp(3rem, 6vh, 6rem) clamp(1.5rem, 4vw, 4rem) clamp(4rem, 9vh, 8rem);
min-height: calc(100vh - var(--sl-nav-height, 5.2rem));
display: flex;
align-items: center;
justify-content: flex-start;
overflow: hidden;
color: #f5f1e8;
background: linear-gradient(
180deg,
#07041a 0%,
#0c0728 30%,
#150a3c 60%,
#2c1a52 85%,
#4a2f6c 100%
);
}
/* Inset frame — a thin paper-coloured rule outlining the cosmic stage,
* like a framed astronomy photograph. Doubles as a scrim that darkens
* the left third so the copy reads cleanly against the spiral arms. */
.galaxy-hero__veil {
position: absolute;
inset: clamp(10px, 1.2vw, 22px);
z-index: 1;
pointer-events: none;
border: 1px solid rgba(196, 181, 253, 0.28);
border-radius: 6px;
box-shadow:
inset 0 0 160px rgba(0, 0, 0, 0.55),
inset 0 0 0 1px rgba(7, 4, 26, 0.6);
background:
/* dark scrim on the left half so text reads through */
linear-gradient(
90deg,
rgba(7, 4, 26, 0.78) 0%,
rgba(7, 4, 26, 0.55) 22%,
rgba(7, 4, 26, 0.18) 42%,
rgba(7, 4, 26, 0) 60%
),
/* faint amethyst glow toward the galactic core */
radial-gradient(ellipse at 62% 50%, rgba(124, 112, 171, 0.10) 0%, transparent 60%);
}
/* Soft fade-to-paper at the bottom. Bezier-eased gradient stops give
* a perceptually-smooth transition: most of the alpha change happens
* in the middle of the fade region, none at the extremes. The bottom
* of this fade exactly matches the body's cream colour — no seam. */
.galaxy-hero::after {
content: '';
position: absolute;
inset: auto 0 -1px 0;
height: 38vh;
min-height: 320px;
background: linear-gradient(
to bottom,
rgba(245, 241, 232, 0) 0%,
rgba(245, 241, 232, 0.01) 12%,
rgba(245, 241, 232, 0.04) 25%,
rgba(245, 241, 232, 0.10) 38%,
rgba(245, 241, 232, 0.22) 50%,
rgba(245, 241, 232, 0.40) 62%,
rgba(245, 241, 232, 0.62) 74%,
rgba(245, 241, 232, 0.82) 86%,
rgba(245, 241, 232, 0.95) 94%,
rgba(245, 241, 232, 1) 100%
);
pointer-events: none;
z-index: 2;
}
:root[data-theme='dark'] .galaxy-hero::after {
background: linear-gradient(
to bottom,
rgba(24, 20, 40, 0) 0%,
rgba(24, 20, 40, 0.10) 30%,
rgba(24, 20, 40, 0.40) 60%,
rgba(24, 20, 40, 0.80) 85%,
rgba(24, 20, 40, 1) 100%
);
}
.galaxy-hero__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
display: block;
}
/* Subtle vignette + atmospheric haze on top of the canvas */
.galaxy-hero__veil {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background:
radial-gradient(ellipse at 65% 50%, rgba(124, 112, 171, 0.12) 0%, transparent 60%),
radial-gradient(ellipse at 100% 100%, rgba(0, 0, 0, 0.55) 0%, transparent 60%);
}
/* Copy floats over the galaxy on the left third of the framed stage.
* The galaxy itself stays visible behind the text via low-opacity backdrop. */
.galaxy-hero__copy {
position: relative;
z-index: 3;
max-width: 32rem;
margin-left: max(2rem, calc((100vw - var(--sl-content-width, 50rem)) / 2));
}
@media (max-width: 768px) {
.galaxy-hero__copy {
margin-left: 1.5rem;
margin-right: 1.5rem;
}
}
.galaxy-hero__number {
position: absolute;
right: clamp(2rem, 5vw, 6rem);
bottom: clamp(2rem, 6vh, 5rem);
font-family: var(--font-display, 'Bricolage Grotesque', sans-serif);
font-variation-settings: 'opsz' 96, 'wght' 200;
font-size: clamp(8rem, 14vw, 16rem);
line-height: 1;
color: rgba(196, 181, 253, 0.10);
letter-spacing: -0.06em;
pointer-events: none;
z-index: 2;
user-select: none;
}
.galaxy-hero__eyebrow {
font-family: var(--font-display, 'Bricolage Grotesque', sans-serif);
font-size: 0.72rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #fbbf24;
margin: 0 0 1.6rem;
display: inline-flex;
align-items: center;
gap: 0.7rem;
}
.galaxy-hero__eyebrow::before {
content: '';
display: inline-block;
width: 24px;
height: 1px;
background: #fbbf24;
}
.galaxy-hero__title {
font-family: var(--font-display, 'Bricolage Grotesque', sans-serif);
font-variation-settings: 'opsz' 96, 'wght' 600;
font-size: clamp(2.6rem, 1.8rem + 4vw, 4.8rem);
line-height: 0.96;
letter-spacing: -0.04em;
margin: 0 0 1.4rem;
color: #ffffff;
max-width: 16ch;
text-shadow: 0 2px 24px rgba(0, 0, 0, 0.5);
}
.galaxy-hero__title em {
font-style: normal;
color: #c4b5fd;
position: relative;
white-space: nowrap;
}
.galaxy-hero__title em::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0.05em;
height: 0.18em;
background: #fbbf24;
opacity: 0.55;
z-index: -1;
box-shadow: 0 0 24px rgba(251, 191, 36, 0.5);
}
.galaxy-hero__lede {
font-family: var(--font-text, 'Bricolage Grotesque', sans-serif);
font-variation-settings: 'opsz' 24, 'wght' 400;
font-size: clamp(1.05rem, 0.9rem + 0.3vw, 1.22rem);
line-height: 1.55;
letter-spacing: -0.012em;
color: rgba(245, 241, 232, 0.85);
max-width: 36ch;
margin: 0 0 2rem;
text-shadow: 0 1px 12px rgba(0, 0, 0, 0.4);
}
.galaxy-hero__lede strong {
color: #ffffff;
font-variation-settings: 'opsz' 24, 'wght' 600;
}
.galaxy-hero__cta {
display: flex;
gap: 0.7rem;
flex-wrap: wrap;
align-items: center;
}
.galaxy-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 1.2rem;
font-family: var(--font-text, 'Bricolage Grotesque', sans-serif);
font-variation-settings: 'opsz' 14, 'wght' 600;
font-size: 0.92rem;
letter-spacing: -0.005em;
border-radius: 3px;
text-decoration: none;
transition: transform 200ms cubic-bezier(0.2, 0.7, 0.2, 1),
box-shadow 200ms,
background-color 200ms;
}
.galaxy-button--primary {
background: linear-gradient(135deg, #fbbf24, #fde68a);
color: #1f1b2e;
border: 1px solid rgba(251, 191, 36, 0.6);
box-shadow: 0 1px 0 rgba(251, 191, 36, 0.4),
0 0 32px rgba(251, 191, 36, 0.25);
}
.galaxy-button--primary:hover {
transform: translateY(-1px);
box-shadow: 0 8px 32px -4px rgba(251, 191, 36, 0.5);
}
.galaxy-button--ghost {
background: rgba(255, 255, 255, 0.06);
color: #f5f1e8;
border: 1px solid rgba(245, 241, 232, 0.3);
backdrop-filter: blur(8px);
}
.galaxy-button--ghost:hover {
border-color: #c4b5fd;
color: #c4b5fd;
}
.galaxy-button .galaxy-button__arrow {
transition: transform 240ms cubic-bezier(0.2, 0.7, 0.2, 1);
}
.galaxy-button:hover .galaxy-button__arrow {
transform: translateX(3px);
}
/* Lotus mark anchored on the right — floating in front of the galaxy */
.galaxy-hero__mark {
width: clamp(150px, 22vw, 290px);
height: auto;
filter:
drop-shadow(0 0 30px rgba(196, 181, 253, 0.45))
drop-shadow(0 0 60px rgba(125, 183, 220, 0.25))
brightness(1.15);
animation: galaxy-breathe 14s ease-in-out infinite;
transition: filter 400ms ease;
}
@keyframes galaxy-breathe {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-8px) rotate(0.8deg); }
}
@media (prefers-reduced-motion: reduce) {
.galaxy-hero__mark { animation: none; }
}
@media (max-width: 768px) {
.galaxy-hero__mark {
width: 130px;
opacity: 0.85;
}
}
</style>
<script is:inline>
(function () {
const canvas = document.getElementById('galaxy-canvas');
if (!canvas) return;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const gl = canvas.getContext('webgl2', {
alpha: true,
antialias: true,
premultipliedAlpha: false,
}) || canvas.getContext('webgl', { alpha: true, antialias: true });
if (!gl) return;
// ---------------------------------------------------------------------------
// Particle generation — a tilted spiral galaxy with 4 arms.
// ---------------------------------------------------------------------------
const PARTICLE_COUNT = 14000;
const positions = new Float32Array(PARTICLE_COUNT * 3);
const colors = new Float32Array(PARTICLE_COUNT * 4);
const sizes = new Float32Array(PARTICLE_COUNT);
const seeds = new Float32Array(PARTICLE_COUNT);
const ARMS = 4;
const ARM_SPREAD = 0.55;
for (let i = 0; i < PARTICLE_COUNT; i++) {
// Bias toward the centre — pow(rand, 0.55) clusters values near zero.
const r = Math.pow(Math.random(), 0.55) * 1.0;
const arm = Math.floor(Math.random() * ARMS);
const baseTheta = (arm * (Math.PI * 2) / ARMS) + Math.log(r + 0.05) * 5.0;
const theta = baseTheta + (Math.random() - 0.5) * ARM_SPREAD * (1 - r);
// Thin disk with slight thickness toward the centre.
const thickness = 0.04 + (1 - r) * 0.10;
const y = (Math.random() - 0.5) * thickness;
positions[i * 3 + 0] = r * Math.cos(theta);
positions[i * 3 + 1] = y;
positions[i * 3 + 2] = r * Math.sin(theta);
// Colour by radius — gold core → violet → cyan → pale lavender outer.
let cr, cg, cb;
if (r < 0.12) {
// Hot bright core
cr = 1.00; cg = 0.93; cb = 0.70;
} else if (r < 0.32) {
cr = 0.99; cg = 0.75; cb = 0.42; // marduk gold
} else if (r < 0.55) {
cr = 0.66; cg = 0.51; cb = 0.96; // amethyst
} else if (r < 0.80) {
cr = 0.49; cg = 0.72; cb = 0.86; // skyfall blue
} else {
cr = 0.92; cg = 0.90; cb = 0.95; // pale lavender
}
// Slight per-particle colour wobble for richness
const jitter = (Math.random() - 0.5) * 0.10;
cr = Math.max(0, Math.min(1, cr + jitter));
cg = Math.max(0, Math.min(1, cg + jitter));
cb = Math.max(0, Math.min(1, cb + jitter));
colors[i * 4 + 0] = cr;
colors[i * 4 + 1] = cg;
colors[i * 4 + 2] = cb;
// Higher base alpha — the additive blend on a near-black canvas needs
// brighter contribution per-particle to make the spiral structure pop.
colors[i * 4 + 3] = 0.65 + Math.random() * 0.35;
sizes[i] = 1.6 + Math.random() * 3.0;
if (r < 0.10) sizes[i] *= 2.6; // hot core particles much bigger
else if (r < 0.30) sizes[i] *= 1.4; // inner arms emphasized
if (r > 0.85) sizes[i] *= 0.85; // outer faint dust slightly smaller
seeds[i] = Math.random();
}
// ---------------------------------------------------------------------------
// Shaders
// ---------------------------------------------------------------------------
const vsSrc = `
precision mediump float;
attribute vec3 aPos;
attribute vec4 aCol;
attribute float aSize;
attribute float aSeed;
uniform float uTime;
uniform float uRatio;
uniform float uPixelRatio;
varying vec4 vCol;
void main() {
// Inner particles rotate faster than outer (like a real galaxy).
// Slow, contemplative pace: outer arms ~ 90s/revolution.
float r = length(aPos.xz);
float speed = 0.28 / (r + 0.12);
float angle = uTime * speed * 0.024;
float c = cos(angle), s = sin(angle);
vec3 p = vec3(c * aPos.x - s * aPos.z, aPos.y, s * aPos.x + c * aPos.z);
// 3/4 perspective view — disk tilted at ~50° so the spiral is clearly
// visible as an ellipse, not a perfect circle. Less face-on than 1.15,
// less edge-on than 0.42; sweet spot for editorial composition.
float tx = 0.85;
float cx = cos(tx), sx = sin(tx);
p = vec3(p.x, cx * p.y - sx * p.z, sx * p.y + cx * p.z);
// Subtle camera drift — Lissajous oscillation, very slow.
vec2 drift = vec2(sin(uTime * 0.018) * 0.04, cos(uTime * 0.013) * 0.03);
// Centred. scale 1.1 so the disk fills the framed stage comfortably
// without clipping at top/bottom.
float scale = 1.1;
vec2 screen = vec2(p.x * scale + drift.x, p.y * 1.0 * scale + drift.y);
screen.x /= uRatio;
gl_Position = vec4(screen, p.z * 0.5, 1.0);
// Twinkle: subtle, only ±10% size modulation, never dims below baseline.
float tw = 0.95 + 0.05 * sin(uTime * 0.6 + aSeed * 6.28);
gl_PointSize = aSize * uPixelRatio * tw;
vCol = aCol;
}
`;
const fsSrc = `
precision mediump float;
varying vec4 vCol;
void main() {
vec2 uv = gl_PointCoord - 0.5;
float d = length(uv);
// Soft round particle with brighter halo; widened halo radius so
// particles bleed into each other and form visible dust lanes.
float core = 1.0 - smoothstep(0.0, 0.22, d);
float halo = 1.0 - smoothstep(0.22, 0.50, d);
float alpha = core + halo * 0.75;
// Add brightness boost in the core for the bloom feel.
gl_FragColor = vec4(vCol.rgb * (1.0 + core * 1.0), vCol.a * alpha);
}
`;
function compile(type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
console.error('shader compile:', gl.getShaderInfoLog(sh));
gl.deleteShader(sh);
return null;
}
return sh;
}
const vs = compile(gl.VERTEX_SHADER, vsSrc);
const fs = compile(gl.FRAGMENT_SHADER, fsSrc);
if (!vs || !fs) return;
const prog = gl.createProgram();
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.error('program link:', gl.getProgramInfoLog(prog));
return;
}
gl.useProgram(prog);
// ---------------------------------------------------------------------------
// Buffers
// ---------------------------------------------------------------------------
function buf(data, attrName, size) {
const b = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, b);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, attrName);
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, size, gl.FLOAT, false, 0, 0);
}
buf(positions, 'aPos', 3);
buf(colors, 'aCol', 4);
buf(sizes, 'aSize', 1);
buf(seeds, 'aSeed', 1);
const uTime = gl.getUniformLocation(prog, 'uTime');
const uRatio = gl.getUniformLocation(prog, 'uRatio');
const uPx = gl.getUniformLocation(prog, 'uPixelRatio');
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE); // additive
gl.disable(gl.DEPTH_TEST);
// ---------------------------------------------------------------------------
// Resize + animation loop
// ---------------------------------------------------------------------------
function resize() {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const rect = canvas.getBoundingClientRect();
canvas.width = Math.max(2, Math.floor(rect.width * dpr));
canvas.height = Math.max(2, Math.floor(rect.height * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform1f(uRatio, canvas.width / canvas.height);
gl.uniform1f(uPx, dpr);
}
resize();
window.addEventListener('resize', resize);
let start = performance.now();
function frame(now) {
const t = (now - start) * 0.001;
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.uniform1f(uTime, t);
gl.drawArrays(gl.POINTS, 0, PARTICLE_COUNT);
if (!reduced) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
// Pause when tab is hidden
document.addEventListener('visibilitychange', () => {
if (!document.hidden && !reduced) requestAnimationFrame(frame);
});
})();
</script>

View File

@@ -0,0 +1,154 @@
---
/**
* LandingFooter — mockup-faithful footer for the splash page.
*
* • 4-column grid: brand · Framework · Models · Community
* • Subtle star wash background (footer-canvas, vanilla canvas2d)
* • Bottom strip: "© 2026 Nibiru · Apache 2.0" + "Built in orbit · v0.9.2"
*
* The mockup's `id="docs"` anchor is preserved so in-page nav `<a href="#docs">`
* still scrolls here.
*/
---
<footer class="footer" id="docs">
<canvas id="footer-canvas" aria-hidden="true"></canvas>
<div class="container footer-inner">
<div class="footer-brand">
<div class="brand">
<img class="brand-mark" src="/img/nibiru-logo.png" alt="" style="height: 30px; width: auto;" />
<span class="brand-name">Nibiru<em></em></span>
</div>
<p>An AI-native MMVC PHP framework. Open source. Open weights when they ship. Built to compose.</p>
</div>
<div class="footer-col">
<h5>Framework</h5>
<ul>
<li><a href="#mmvc">MMVC pattern</a></li>
<li><a href="#code">Quick start</a></li>
<li><a href="/en/core/modules/">Modules</a></li>
<li><a href="/en/start/what-is-nibiru/">Reference</a></li>
</ul>
</div>
<div class="footer-col">
<h5>AI module</h5>
<ul>
<li><a href="/en/ai/module/overview/">Overview</a></li>
<li><a href="/en/ai/module/rag/">RAG plugin</a></li>
<li><a href="/en/ai/module/agent/">Agent plugin</a></li>
<li><a href="/en/ai/oracle/">The Oracle</a></li>
</ul>
</div>
<div class="footer-col">
<h5>Community</h5>
<ul>
<li><a href="https://github.com/alllinux/Nibiru">GitHub</a></li>
<li><a href="/en/showcase/projects/">Showcase</a></li>
<li><a href="/en/why-nibiru/">Why Nibiru</a></li>
<li><a href="/en/ai/roadmap/">Roadmap</a></li>
</ul>
</div>
</div>
<div class="container footer-bottom">
<span>© 2026 Nibiru · MIT licensed</span>
<span>Built in orbit · v0.9.2</span>
</div>
</footer>
<style is:global>
.footer {
position: relative;
padding: 100px 0 60px;
background: var(--nibiru-space);
border-top: 1px solid var(--nibiru-line);
overflow: hidden;
color: var(--nibiru-star);
/* Break out of any centring container — the footer is full-bleed */
width: 100vw;
margin-left: 50%;
margin-right: 50%;
transform: translateX(-50%);
}
.footer #footer-canvas {
position: absolute; inset: 0;
width: 100%; height: 100%;
opacity: 0.4;
pointer-events: none;
z-index: 0;
}
.footer-inner {
position: relative; z-index: 2;
display: grid;
grid-template-columns: 1.4fr 1fr 1fr 1fr;
gap: 48px;
max-width: var(--nibiru-container, 1280px);
margin: 0 auto;
padding: 0 32px;
}
@media (max-width: 768px) {
.footer-inner { grid-template-columns: 1fr 1fr; }
}
.footer-brand .brand {
margin-bottom: 16px;
display: flex; align-items: center; gap: 12px;
text-decoration: none;
}
.footer-brand .brand-name {
font-family: var(--font-display);
font-weight: 500;
font-size: 18px;
letter-spacing: -0.02em;
color: var(--nibiru-star);
}
.footer-brand .brand-name em { font-style: normal; font-weight: 300; }
.footer-brand p {
font-size: 14px;
color: rgba(244, 238, 219, 0.55);
max-width: 36ch;
line-height: 1.55;
margin: 12px 0 0;
}
.footer-col h5 {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--nibiru-muted);
margin: 0 0 18px;
font-weight: 400;
}
.footer-col ul {
list-style: none;
padding: 0; margin: 0;
display: flex; flex-direction: column; gap: 12px;
}
.footer-col a {
font-size: 14px;
color: rgba(244, 238, 219, 0.7);
text-decoration: none;
transition: color 160ms ease;
}
.footer-col a:hover { color: var(--nibiru-star); }
.footer-bottom {
margin-top: 80px;
padding-top: 24px;
border-top: 1px solid var(--nibiru-line);
display: flex;
justify-content: space-between;
align-items: center;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--nibiru-muted);
position: relative;
z-index: 2;
max-width: var(--nibiru-container, 1280px);
margin-left: auto;
margin-right: auto;
padding-left: 32px;
padding-right: 32px;
flex-wrap: wrap;
gap: 12px;
}
</style>

View File

@@ -0,0 +1,22 @@
---
/**
* LandingScripts — loads the original mockup three.js + nibiru-scene.js
* verbatim from /js/. Drop these at the END of the splash page so every
* element they reference (#nav, #constellation, #mmvc-canvas, #mcBody,
* #launchBody, #launch-canvas, #footer-canvas, #moduleTip, #toTop, …)
* is already in the DOM when the scripts execute.
*
* The two files are extracted verbatim from the bundler-wrapped mockup
* (/Nibiru/nibiru-startpage.html) — three.js (~600KB) and the scene code
* (~38KB). They are mirrored to public/js/ on every build via
* scripts/sync-design-system.mjs.
*
* Mission Control chat posts to /api/oracle (Astro endpoint at
* src/pages/api/oracle.ts) which routes to Ollama via providers.mjs.
* Production points the Ollama base URL at api.neuronetz.ai. The fallback
* "Link interrupted. Re-establishing controller…" message now only fires
* if the fetch itself fails or the endpoint returns non-2xx.
*/
---
<script is:inline src="/js/three.min.js"></script>
<script is:inline src="/js/nibiru-scene.js"></script>

View File

@@ -0,0 +1,37 @@
---
/**
* Launch Sequence — code on the left, simulated system canvas on the right.
* Hit "▶ Launch" to type the code line-by-line; the canvas reacts by
* spinning up modules and updating the stats.
*/
---
<section class="section" id="code" aria-labelledby="code-h">
<div class="container">
<div class="section-head">
<span class="eyebrow">Launch sequence · 03</span>
<h2 id="code-h" class="section-title">Eleven lines, <span class="accent">one orbit.</span></h2>
<p class="section-sub">Code on the left, system on the right. Hit launch and watch the modules slide into place as the controller wires them up.</p>
</div>
</div>
<div class="launch-grid">
<div class="launch-code">
<div class="launch-code-head">
<span class="file">agent.php</span>
<button class="play" id="launchPlay" type="button">▶ Launch</button>
</div>
<pre class="launch-code-body" id="launchBody"></pre>
</div>
<div class="launch-system">
<canvas id="launch-canvas" aria-hidden="true"></canvas>
<div class="legend">// nibiru system · live</div>
<div class="stats" id="launchStats">
<span>Modules <strong id="lsModules">0</strong></span>
<span>Tokens <strong id="lsTokens">0</strong></span>
<span>Latency <strong id="lsLatency">— ms</strong></span>
</div>
</div>
</div>
</section>

View File

@@ -0,0 +1,52 @@
---
/**
* Mission Control — terminal-style mock chat with simulated streaming.
* Wires to /api/oracle if available, otherwise streams a canned response
* from a small response table. Fully client-rendered, no JS frameworks.
*/
---
<section class="section" id="chat" aria-labelledby="chat-h">
<div class="container">
<div class="section-head">
<span class="eyebrow">Live system · 02</span>
<h2 id="chat-h" class="section-title">Mission <span class="accent">Control.</span></h2>
<p class="section-sub">A working playground. Same MMVC controller — just with the dashboard turned on so you can see the modules light up.</p>
</div>
<div class="mc-frame" id="mcFrame">
<div class="mc-header">
<div class="mc-id"><span class="dot"></span><span>NIBIRU/MC · session 7c4a</span></div>
<div class="mc-tele" id="mcTele">
<span>MODEL <strong>nibiru-base-7B</strong></span>
<span>LATENCY <strong id="mcLatency">—</strong></span>
<span>TOKENS <strong id="mcTokens">0</strong></span>
</div>
<div class="mc-actions">
<button title="Reset" id="mcReset" type="button" aria-label="Reset session">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 3v5h5"/>
</svg>
</button>
</div>
</div>
<div class="mc-body" id="mcBody">
<div class="mc-line sys"> establishing link to nibiru-base-7B …</div>
<div class="mc-line sys"> controller online · 4 modules in orbit (Retriever, ReActPlanner, Greeter, ChatView)</div>
<div class="mc-line sys"> ready. type a prompt below or pick a suggestion.</div>
</div>
<div class="mc-input">
<span class="prompt"></span>
<input id="mcInput" type="text" placeholder="ask the agent…" autocomplete="off" />
<button class="send" id="mcSend" type="button">Transmit</button>
</div>
</div>
<div class="mc-suggestions">
<button class="mc-suggestion" data-q="Explain MMVC" type="button">Explain MMVC</button>
<button class="mc-suggestion" data-q="Build a RAG agent" type="button">Build a RAG agent</button>
<button class="mc-suggestion" data-q="Add a tool to my Nibiru module" type="button">Add a tool</button>
<button class="mc-suggestion" data-q="Write a haiku about modules" type="button">Write a haiku</button>
</div>
</div>
</section>

View File

@@ -0,0 +1,220 @@
---
/**
* MMVC pinned narrative — five-panel sticky-scroll sequence.
*
* Roles: Model · AI · Module · Controller · View. The "AI" panel was added
* as a first-class step between Model and Module — Nibiru ships an AI module
* (chat / embed / RAG / agent plugins) that wraps raw weights into intent.
*
* CSS is a faithful port of design-system/source/index-v2.html §MMVC PINNED
* NARRATIVE (lines 408-518). Markup mirrors the mockup, with one extra panel
* + step. Track height scaled from 400vh (4 panels) to 500vh (5 panels).
*
* The scroll-spy + camera presets live in public/js/nibiru-scene.js — that
* file's step math (Math.floor(progress * N) and clamp) was bumped from 4→5
* in the same change.
*/
---
<section class="section mmvc-stage" id="mmvc" aria-labelledby="mmvc-h">
<div class="container">
<div class="section-head">
<span class="eyebrow">The pattern · 01</span>
<h2 id="mmvc-h" class="section-title">Five roles. <span class="accent">One orbit.</span></h2>
<p class="section-sub">MMVC generalises classic MVC for AI workloads. The monolithic Model splits — first into weights, then into the AI patterns and typed capabilities that wrap them. Watch the system come into focus.</p>
</div>
</div>
<div class="mmvc-track" id="mmvcTrack">
<div class="mmvc-pin">
<!-- Progress rail -->
<div class="mmvc-progress" aria-hidden="true">
<div class="step active" data-step="0"><span class="bar"></span><span>Model</span></div>
<div class="step" data-step="1"><span class="bar"></span><span>AI</span></div>
<div class="step" data-step="2"><span class="bar"></span><span>Module</span></div>
<div class="step" data-step="3"><span class="bar"></span><span>Controller</span></div>
<div class="step" data-step="4"><span class="bar"></span><span>View</span></div>
</div>
<div class="mmvc-copy">
<div class="panel active" data-panel="0">
<div class="step-num">01 / 05 — MODEL</div>
<h3>The <span class="pop">star</span><br/>at the centre.</h3>
<p>The <strong>Model</strong> is the underlying ML model. Weights, runtime, inference — wherever it lives, on a laptop or a cluster. From the Controller's view it is stateless and addressable: <code>Model::load('nibiru-base-7B')</code>.</p>
<p>One Model, many Modules.</p>
</div>
<div class="panel" data-panel="1">
<div class="step-num">02 / 05 — AI</div>
<h3>The <span class="pop">spark</span><br/>that lights it up.</h3>
<p>The <strong>AI</strong> module turns weights into intent. Chat, embeddings, retrieval, agents — the inference patterns Nibiru ships with, wrapped around any Model the runtime can reach.</p>
<p>From <code>Model::load()</code> to <code>Ai::ask()</code> in one hop.</p>
</div>
<div class="panel" data-panel="2">
<div class="step-num">03 / 05 — MODULE</div>
<h3>Modules <span class="pop">orbit</span><br/>the Model.</h3>
<p>A <strong>Module</strong> is a typed capability — a retriever, a planner, a tool, a vision encoder. It has typed inputs and outputs. Composable. Pure-function-shaped over the Model.</p>
<p>This is the layer MVC never had. Capabilities live here, not in the Model.</p>
</div>
<div class="panel" data-panel="3">
<div class="step-num">04 / 05 — CONTROLLER</div>
<h3>The <span class="pop">gravity</span><br/>between them.</h3>
<p>The <strong>Controller</strong> orchestrates. It decides which Modules run, in what order, and how their outputs flow. Holds policy, never weights — the same Controller drives a chat agent or a batch job.</p>
</div>
<div class="panel" data-panel="4">
<div class="step-num">05 / 05 — VIEW</div>
<h3>The <span class="pop">surface</span><br/>at the horizon.</h3>
<p>The <strong>View</strong> is the user-facing layer — <code>ChatView</code>, <code>VoiceView</code>, <code>APIView</code>. Swap one for another and the Controller does not change. Same agent, different sky.</p>
</div>
</div>
<div class="mmvc-visual" id="mmvcVisual">
<canvas id="mmvc-canvas" aria-hidden="true"></canvas>
</div>
</div>
</div>
</section>
<style is:global>
/* ============== MMVC PINNED NARRATIVE — verbatim from index-v2.html
§lines 408-518 (4-panel mockup), with .mmvc-track height scaled from
400vh → 500vh for the 5th panel. ============== */
.mmvc-stage {
position: relative;
padding: 0;
}
.mmvc-track {
position: relative;
height: 500vh; /* 5 panels worth of scroll (mockup was 400vh for 4) */
}
.mmvc-pin {
position: sticky; top: 0;
height: 100vh;
display: block;
overflow: hidden;
background: radial-gradient(ellipse at 50% 50%, var(--nibiru-plum, #1c0f3a), var(--nibiru-space, #0a0414) 60%);
}
.mmvc-visual {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
z-index: 0;
opacity: 0.45;
}
#mmvc-canvas {
position: absolute; inset: 0;
width: 100%; height: 100%;
}
.mmvc-copy {
position: absolute; inset: 0;
z-index: 2;
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 0 24px;
text-align: center;
}
.mmvc-copy .panel {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, calc(-50% + 24px));
display: flex; flex-direction: column; align-items: center;
width: min(720px, calc(100vw - 48px));
text-align: center;
opacity: 0;
transition: opacity 500ms ease, transform 500ms ease;
pointer-events: none;
}
.mmvc-copy .panel.active {
opacity: 1; transform: translate(-50%, -50%);
pointer-events: auto;
}
.mmvc-copy .step-num {
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 12px; letter-spacing: 0.18em;
color: var(--nibiru-nebula-mag, #b86bff);
text-transform: uppercase;
margin-bottom: 24px;
display: inline-flex; align-items: center; gap: 12px;
justify-content: center;
}
.mmvc-copy .step-num::before,
.mmvc-copy .step-num::after {
content: ''; width: 28px; height: 1px;
background: var(--nibiru-nebula-mag, #b86bff);
opacity: 0.6;
}
.mmvc-copy h3 {
font-family: 'Space Grotesk', 'Inter Tight', ui-sans-serif, sans-serif;
font-size: clamp(48px, 6vw, 88px);
font-weight: 400;
line-height: 0.98;
letter-spacing: -0.035em;
margin: 0 0 24px;
color: var(--nibiru-star, #f4eedb);
}
.mmvc-copy h3 em { font-style: normal; font-weight: 500; }
.mmvc-copy h3 .pop {
background: linear-gradient(110deg, #ffb574, #b86bff);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
font-style: normal; font-weight: 500;
}
.mmvc-copy p {
font-size: 20px; line-height: 1.55;
color: rgba(244, 238, 219, 0.85);
max-width: 56ch;
margin: 0 auto 16px;
text-wrap: pretty;
}
.mmvc-copy code {
background: rgba(244, 238, 219, 0.06);
border: 1px solid var(--nibiru-line, rgba(244, 238, 219, 0.12));
padding: 1px 6px;
border-radius: 4px;
font-size: 0.85em;
font-family: 'JetBrains Mono', ui-monospace, monospace;
color: var(--nibiru-iris-soft, #d4b4ff);
}
.mmvc-progress {
position: absolute;
left: 50%;
top: 80px;
transform: translateX(-50%);
display: flex; gap: 8px; align-items: center;
z-index: 4;
flex-wrap: nowrap;
justify-content: center;
white-space: nowrap;
padding: 0 16px;
}
.mmvc-progress .step {
display: flex; align-items: center; gap: 8px;
color: var(--nibiru-muted, #6e6680);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11px; letter-spacing: 0.10em; text-transform: uppercase;
transition: color 240ms ease;
padding-right: 10px;
}
.mmvc-progress .step .bar {
width: 24px; height: 2px;
background: var(--nibiru-line, rgba(244, 238, 219, 0.12));
transition: background 240ms ease, width 240ms ease;
border-radius: 2px;
}
.mmvc-progress .step.active { color: var(--nibiru-star, #f4eedb); }
.mmvc-progress .step.active .bar {
background: var(--nibiru-nebula-mag, #b86bff);
width: 40px;
}
@media (max-width: 720px) {
.mmvc-progress { gap: 4px; font-size: 9px; padding: 0 8px; }
.mmvc-progress .step { gap: 4px; padding-right: 4px; }
.mmvc-progress .step .bar { width: 14px; }
.mmvc-progress .step.active .bar { width: 24px; }
}
@media (max-width: 960px) {
.mmvc-track { height: 460vh; }
.mmvc-copy h3 { font-size: clamp(36px, 8vw, 56px); }
.mmvc-copy p { font-size: 16px; }
.mmvc-progress { top: 32px; }
}
</style>

View File

@@ -0,0 +1,101 @@
---
/**
* PageTitle override — emits the docs-page mockup's article header:
* .breadcrumbs · <h1> with <em> gradient accent · .doc-lede · .doc-meta
*
* Replaces Starlight's bare-<h1> default at
* node_modules/@astrojs/starlight/components/PageTitle.astro
*
* Data comes from Astro.locals.starlightRoute. Styles live in
* src/styles/starlight-docs-bridge.css §08 (.breadcrumbs / .doc-lede / .doc-meta)
*/
// Starlight's internal PAGE_TITLE_ID constant (not exported publicly).
// Inlined here so the H1 anchor matches what Starlight's TOC scroll-spy expects.
const PAGE_TITLE_ID = '_top';
const route = Astro.locals.starlightRoute;
const { entry, sidebar, lastUpdated, editUrl, locale } = route;
const title = entry.data.title;
const description = entry.data.description;
// Wrap last word of title in <em> for the amber→magenta gradient accent.
// Pages can opt out by setting frontmatter `accent: false`.
const accent = (entry.data as Record<string, unknown>).accent;
let titleHead = title;
let titleTail = '';
if (accent !== false) {
const idx = title.lastIndexOf(' ');
if (idx > 0) {
titleHead = title.slice(0, idx);
titleTail = title.slice(idx + 1);
}
}
// Breadcrumbs — walk sidebar to find the current entry's ancestry.
type SidebarItem = { label: string; href?: string; isCurrent?: boolean; entries?: SidebarItem[]; type?: string };
function findCrumbs(items: SidebarItem[], trail: SidebarItem[] = []): SidebarItem[] | null {
for (const item of items) {
const next = [...trail, item];
if (item.isCurrent) return next;
if (item.entries) {
const found = findCrumbs(item.entries, next);
if (found) return found;
}
}
return null;
}
const crumbs = findCrumbs(sidebar as SidebarItem[]) ?? [];
// "Docs" root crumb (localized via Starlight i18n)
const docsRoot = locale ? `/${locale}/` : '/';
// Reading time — rough estimate from rendered body word count (250 wpm).
const bodyText = (entry.body ?? '').replace(/[`*_#>\-]+/g, ' ').replace(/\s+/g, ' ').trim();
const wordCount = bodyText ? bodyText.split(' ').length : 0;
const minutes = Math.max(1, Math.round(wordCount / 250));
// Last-updated — relative format ("2 days ago")
let updatedRel: string | null = null;
if (lastUpdated) {
const now = Date.now();
const diffMs = now - lastUpdated.getTime();
const day = 86400000;
const rtf = new Intl.RelativeTimeFormat(locale ?? 'en', { numeric: 'auto' });
if (diffMs < day) updatedRel = rtf.format(-Math.round(diffMs / 3600000), 'hour');
else if (diffMs < 30 * day) updatedRel = rtf.format(-Math.round(diffMs / day), 'day');
else if (diffMs < 365 * day) updatedRel = rtf.format(-Math.round(diffMs / (30 * day)), 'month');
else updatedRel = rtf.format(-Math.round(diffMs / (365 * day)), 'year');
}
// Stability badge — read frontmatter `status` (stable | beta | experimental); default 'Stable'
const status = ((entry.data as Record<string, unknown>).status as string | undefined) ?? 'stable';
const statusLabel = status.charAt(0).toUpperCase() + status.slice(1);
---
{crumbs.length > 0 && (
<nav class="breadcrumbs" aria-label="Breadcrumb">
<a href={docsRoot}>Docs</a>
<span class="sep">/</span>
{crumbs.map((c, i) => (
i === crumbs.length - 1
? <span>{c.label}</span>
: <>
{c.href ? <a href={c.href}>{c.label}</a> : <span>{c.label}</span>}
<span class="sep">/</span>
</>
))}
</nav>
)}
<h1 id={PAGE_TITLE_ID}>
{titleHead}{titleTail && <> <em>{titleTail}</em></>}
</h1>
{description && <p class="doc-lede">{description}</p>}
<div class="doc-meta">
<span><span class="pulse"></span> <strong>{statusLabel}</strong></span>
{updatedRel && <span>Updated <strong>{updatedRel}</strong></span>}
{minutes > 0 && <span>Reading time <strong>~ {minutes} min</strong></span>}
{editUrl && <span>Edit on <a href={editUrl.toString()}><strong>GitHub</strong></a></span>}
</div>

View File

@@ -0,0 +1,97 @@
---
/**
* Spacecraft — three "vessels" (Lite / Base / Pro). Pure CSS, no JS.
* Maps to Nibiru's actual offering:
* Lite — clone & start (the framework alone)
* Base — recommended, with the AI module + Oracle
* Pro — full deployment with CMS, ACL, Elasticsearch and Machineryscout
*/
---
<section class="section" id="download" aria-labelledby="dl-h">
<div class="container">
<div class="section-head">
<span class="eyebrow">Choose your craft · 04</span>
<h2 id="dl-h" class="section-title">Three vessels. <span class="accent">Same orbit.</span></h2>
<p class="section-sub">Open source, MIT-licensed, no SDK. Pick the configuration that matches your altitude.</p>
</div>
</div>
<div class="craft-grid">
<article class="craft lite">
<span class="badge">Probe · Edge</span>
<h3 class="name">Nibiru <em>Lite</em></h3>
<p class="role">Just the framework. Five commands and you're running.</p>
<div class="silhouette" aria-hidden="true">
<svg viewBox="0 0 100 100" fill="none" stroke="currentColor" stroke-width="1.2" style="color: rgba(122, 214, 163, 0.9);">
<circle cx="50" cy="50" r="8" fill="currentColor"></circle>
<ellipse cx="50" cy="50" rx="32" ry="6"></ellipse>
<ellipse cx="50" cy="50" rx="32" ry="6" transform="rotate(60 50 50)"></ellipse>
<ellipse cx="50" cy="50" rx="32" ry="6" transform="rotate(-60 50 50)"></ellipse>
<circle cx="82" cy="50" r="2.5" fill="currentColor"></circle>
</svg>
</div>
<div class="stats">
<div><span class="l">Disk</span><span class="v">~ 5 MB</span></div>
<div><span class="l">Boot</span><span class="v">3 min</span></div>
<div><span class="l">DB drivers</span><span class="v">5</span></div>
<div><span class="l">License</span><span class="v">MIT</span></div>
</div>
<a class="craft-cta" href="/en/start/quick-start/">Read the quick start <span class="arrow">→</span></a>
</article>
<article class="craft base">
<span class="badge">Shuttle · Daily driver</span>
<h3 class="name">Nibiru <em>Base</em></h3>
<p class="role">Framework + AI module + Oracle, wired to your Ollama.</p>
<div class="silhouette" aria-hidden="true">
<svg viewBox="0 0 100 100" fill="none" stroke="currentColor" stroke-width="1.2" style="color: rgba(184, 107, 255, 0.9);">
<circle cx="50" cy="50" r="14" fill="currentColor" opacity="0.4"></circle>
<circle cx="50" cy="50" r="9" fill="currentColor"></circle>
<ellipse cx="50" cy="50" rx="38" ry="9"></ellipse>
<ellipse cx="50" cy="50" rx="38" ry="9" transform="rotate(45 50 50)"></ellipse>
<ellipse cx="50" cy="50" rx="38" ry="9" transform="rotate(90 50 50)"></ellipse>
<ellipse cx="50" cy="50" rx="38" ry="9" transform="rotate(-45 50 50)"></ellipse>
<circle cx="88" cy="50" r="3" fill="currentColor"></circle>
<circle cx="22" cy="22" r="2" fill="currentColor"></circle>
<circle cx="78" cy="78" r="2.5" fill="currentColor"></circle>
</svg>
</div>
<div class="stats">
<div><span class="l">Modules</span><span class="v">+ ai · oracle</span></div>
<div><span class="l">Tools</span><span class="v">3 built-in</span></div>
<div><span class="l">Backends</span><span class="v">Ollama · OpenAI · Claude</span></div>
<div><span class="l">License</span><span class="v">MIT</span></div>
</div>
<a class="craft-cta" href="/en/ai/module/overview/">Read the AI docs <span class="arrow">→</span></a>
</article>
<article class="craft pro">
<span class="badge">Mothership · Heavy lift</span>
<h3 class="name">Nibiru <em>Pro</em></h3>
<p class="role">Production stack — CMS, ACL, Elasticsearch, blue-green deploys.</p>
<div class="silhouette" aria-hidden="true">
<svg viewBox="0 0 100 100" fill="none" stroke="currentColor" stroke-width="1.2" style="color: rgba(255, 181, 116, 0.9);">
<circle cx="50" cy="50" r="20" fill="currentColor" opacity="0.25"></circle>
<circle cx="50" cy="50" r="12" fill="currentColor"></circle>
<ellipse cx="50" cy="50" rx="44" ry="12"></ellipse>
<ellipse cx="50" cy="50" rx="44" ry="12" transform="rotate(30 50 50)"></ellipse>
<ellipse cx="50" cy="50" rx="44" ry="12" transform="rotate(60 50 50)"></ellipse>
<ellipse cx="50" cy="50" rx="44" ry="12" transform="rotate(90 50 50)"></ellipse>
<ellipse cx="50" cy="50" rx="44" ry="12" transform="rotate(120 50 50)"></ellipse>
<ellipse cx="50" cy="50" rx="44" ry="12" transform="rotate(150 50 50)"></ellipse>
<circle cx="92" cy="50" r="3.5" fill="currentColor"></circle>
<circle cx="14" cy="36" r="2.5" fill="currentColor"></circle>
<circle cx="68" cy="86" r="3" fill="currentColor"></circle>
<circle cx="32" cy="14" r="2" fill="currentColor"></circle>
</svg>
</div>
<div class="stats">
<div><span class="l">Modules</span><span class="v">+ cms · acl · search</span></div>
<div><span class="l">Migrations</span><span class="v">161 in prod</span></div>
<div><span class="l">Search</span><span class="v">Elasticsearch</span></div>
<div><span class="l">Deploy</span><span class="v">blue-green</span></div>
</div>
<a class="craft-cta" href="/en/showcase/projects/">See it in production <span class="arrow">→</span></a>
</article>
</div>
</section>

View File

@@ -0,0 +1,89 @@
---
interface Props {
token: string;
hex: string;
name: string;
usage?: string;
}
const { token, hex, name, usage } = Astro.props as Props;
---
<div class="swatch">
<div class="swatch__chip" style={`background:${hex}`} aria-hidden="true"></div>
<div class="swatch__meta">
<strong class="swatch__name">{name}</strong>
<code class="swatch__token">{token}</code>
<code class="swatch__hex">{hex}</code>
{usage && <p class="swatch__usage">{usage}</p>}
</div>
</div>
<style>
.swatch {
display: grid;
grid-template-columns: 96px 1fr;
gap: 1.2rem;
align-items: center;
padding: 0.75rem 0;
margin: 0;
border-bottom: 1px solid rgba(31, 27, 46, 0.08);
}
.swatch:last-child { border-bottom: 0; }
.swatch__chip {
width: 96px;
height: 64px;
border-radius: 3px;
box-shadow:
inset 0 0 0 1px rgba(0, 0, 0, 0.04),
0 2px 8px -3px rgba(31, 27, 46, 0.20);
}
.swatch__meta {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.swatch__name {
font-family: 'Bricolage Grotesque', system-ui, sans-serif;
font-variation-settings: 'opsz' 24, 'wght' 600;
letter-spacing: -0.018em;
color: var(--ink, #1f1b2e);
font-size: 1.05rem;
margin-bottom: 0.05rem;
}
.swatch__token {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.74rem;
color: var(--iris-deep, #5e548c);
background: transparent;
padding: 0;
border: 0;
}
.swatch__hex {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.74rem;
color: var(--ink-faint, #847b94);
background: transparent;
padding: 0;
border: 0;
}
.swatch__usage {
margin: 0.4rem 0 0;
font-size: 0.85rem;
color: var(--ink-soft, #4a4258);
line-height: 1.55;
max-width: 38rem;
font-variation-settings: 'opsz' 14, 'wght' 400;
}
:root[data-theme='dark'] .swatch { border-bottom-color: rgba(236, 230, 243, 0.10); }
:root[data-theme='dark'] .swatch__name { color: #ece6f3; }
:root[data-theme='dark'] .swatch__token { color: #c2dcec; }
:root[data-theme='dark'] .swatch__hex { color: #9c92ad; }
:root[data-theme='dark'] .swatch__usage { color: #c5bfd1; }
</style>

View File

@@ -0,0 +1,21 @@
---
/*
* Theme picker stub — Nibiru is dark-only.
*
* The Cosmos design system was designed dark-first; the light theme
* was kept as a tinker option, but per the brief we ship a single,
* consistent night-sky experience. This component replaces Starlight's
* default ThemeSelect with nothing so the picker disappears from the
* header, and an inline script in <head> forces data-theme="dark" so
* any prior `localStorage` light setting is overridden.
*/
---
<script is:inline>
// Force-dark for the entire site. Stomps any cached preference and
// any Starlight theme-script that might have run before.
(function () {
try { localStorage.setItem('starlight-theme', 'dark'); } catch (e) {}
document.documentElement.setAttribute('data-theme', 'dark');
})();
</script>

View File

@@ -0,0 +1,13 @@
---
/**
* ToTop — fixed scroll-to-top button. Becomes visible after 600 px of scroll.
* Lives in its own component because inline `<script>` blocks inside .mdx
* files are parsed by MDX, which trips on object-literal braces.
*/
---
<button class="to-top" id="toTop" aria-label="Back to top" type="button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 19V5M5 12l7-7 7 7"/>
</svg>
</button>

View File

@@ -0,0 +1,7 @@
import { defineCollection } from 'astro:content';
import { docsLoader } from '@astrojs/starlight/loaders';
import { docsSchema } from '@astrojs/starlight/schema';
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
};

View File

@@ -0,0 +1,124 @@
---
title: "Trainingskorpus"
description: "Wie die Dokumente als LoRA-fähige Trainingsmenge exportiert werden und wie sie neu generiert werden können."
---
Jede Seite in dieser Dokumentation ist auch ein **Trainingsdatenpunkt**. Nibiru bietet ein Skript, das einen sauberen JSONL-Korpus extrahiert, der für die LoRA-Fine-Tuning eines offenen Modells wie Llama, Mistral, Qwen oder Gemma auf spezifische Kenntnisse von Nibiru geeignet ist.
## Führen Sie es aus
```bash
cd docs
npm run build:corpus
```
Dies schreibt:
```
docs/dist/corpus/
├── instructions.jsonl # instruction → response pairs
├── chat.jsonl # OpenAI/Anthropic chat-message format
├── completion.jsonl # plain prompt → completion (legacy)
└── chunks.jsonl # raw Markdown chunks (one per H2/H3 section)
```
## Formate
### `anweisungen.jsonl`
LoRA-freundliche Anweisungsoptimierung:
```json
{
"instruction": "How do I scaffold a new module in Nibiru?",
"input": "",
"output": "Run `./nibiru -m <name>`, optionally with `-g` for Graylog hooks. This creates `application/module/<name>/` with traits/, plugins/, interfaces/, settings/<name>.ini and the main `<name>.php` class implementing `IModule`."
}
```
Jede Eintragung wird aus einem Dokumentationsabschnitt generiert, der eine klare Frage (abgeleitet vom H2/H3-Titel) und den Abschnittstext als Antwort enthält.
### `chat.jsonl`
OpenAI Chat / Anthropic Nachrichtenformat:
```json
{
"messages": [
{"role": "system", "content": "You are an expert on the Nibiru PHP framework."},
{"role": "user", "content": "How do I scaffold a new module?"},
{"role": "assistant", "content": "Run `./nibiru -m <name>`. …"}
]
}
```
Kompatibel mit OpenAI Fine-Tunes, der API von Anthropic zur Bewertung und den meisten LoRA-Tools, die Eingaben im Chat-Format erwarten (Vorlage `sharegpt` von Axolotl, Unsloth, LLaMA-Factory).
### `chunks.jsonl`
Rohdaten für die Verwendung als RAG-Retrieval-Daten:
```json
{
"id": "core/modules#observer-pattern",
"title": "The observer pattern",
"url": "/core/modules/#the-observer-pattern",
"section": "core/modules",
"language": "en",
"tokens": 412,
"content": "Modules implementing `SplSubject` can broadcast events…"
}
```
Dies ist genau die Datei, die intern von [Oracle](/ai/oracle/) verwendet wird.
## Wie Chunks abgeleitet werden
Der Korpus-Bauer durchläuft jede `.md` / `.mdx`-Datei unter `src/content/docs/`, analysiert sie in einen AST und teilt sie an den Grenzen von H2/H3 auf. Er erfordert:
- Ein Abschnitt pro H2-Kapitel (oder H3, wenn das H2 leer ist).
- ~200800 Token pro Abschnitt (teilen Sie es auf, wenn es länger ist, zusammenführen Sie es, wenn es kürzer ist).
- Codeblöcke bleiben unverändert — teilen Sie sie niemals in der Mitte.
- Jeder Abschnitt trägt seinen Quellpfad, seine Anker-URL und den Sprachcode bei.
Das Skript befindet sich in `scripts/build-corpus.mjs` und ist vollständig konfigurierbar.
## Vorgeschlagene LoRA-Rezepte
Ein pragmatischer Ausgangspunkt für ein 8-Billionen-Parameter-Grundmodell auf einer einzelnen A100 / 4090:
```yaml
# axolotl.yaml
base_model: meta-llama/Llama-3.1-8B-Instruct
adapter: lora
lora_r: 16
lora_alpha: 32
lora_dropout: 0.05
lora_target_modules: [q_proj, k_proj, v_proj, o_proj]
datasets:
- path: docs/dist/corpus/chat.jsonl
type: sharegpt
sequence_len: 4096
sample_packing: true
gradient_accumulation_steps: 4
micro_batch_size: 2
num_epochs: 3
optimizer: adamw_bnb_8bit
learning_rate: 0.0002
warmup_ratio: 0.05
bf16: true
```
Trainen Sie dann die LoRA-Gewichte zusammen und stellen Sie sie über Ollama, vLLM oder text-generation-inference bereit. Ändern Sie den `MODEL`-Eintrag des Orakels auf Ihren lokalen Endpunkt, und Sie haben eine vollständig Nibiru-native Chat-Benutzererfahrung.
## Erneut ausführen bei jeder Dokumentenänderung
Verbinden Sie es in Ihre CI ein:
```yaml
- name: Build corpus
run: cd docs && npm run build:corpus
- name: Upload corpus artifact
uses: actions/upload-artifact@v4
with:
name: nibiru-corpus
path: docs/dist/corpus/
```
Wenn die Dokumentationen sich ändern, wird das Korpus neu erstellt; Consumer (Trainingspipelines, RAG-Indizes) haben immer die neuesten Daten.
## Sprachen
Der Korpus beachtet die Lokalisation. Seiten unter `en/` sind mit `language: en` markiert, deutsche Seiten mit `language: de` und so weiter. Trainieren Sie monolinguale oder mehrsprachige LoRAs, indem Sie das JSONL nach dem Feld `language` filtern.
## Lizenz
Die Dokumentation ist unter der gleichen BSD-4-Klausel lizenziert wie das Framework selbst. Der exportierte Korpus erbt diese Lizenz Sie sind freigestellt, Modelle auf ihm für den kommerziellen Gebrauch zu feinabzustimmen, wobei eine Zitierung erforderlich ist.

View File

@@ -0,0 +1,138 @@
---
title: "Agent-Plugin"
description: "Ein ReAct-stiliges Werkzeugnutzungs-Agent. Erweitern Sie das Werkzeug, um ihm jede PHP-Fähigkeit zu geben, die Sie schreiben können."
---
Das Agent-Plugin ermöglicht es Ihnen, einer LLM **die Fähigkeit zu geben** — SQL-Abfragen auszuführen, HTTP-Endpunkte anzusprechen, Dateien zu lesen oder alles andere zu tun, was Sie als PHP-Methode ausdrücken können. Es führt eine ReAct-artige Schleife durch: Denken → Werkzeug-Aufruf → Beobachten → Wiederholen → Antworten.
## Fünf Zeilen, ein Agent
```php
use Nibiru\Module\Ai\Ai;
use Nibiru\Module\Ai\Plugin\Tools\PdoQuery;
$ai = new Ai();
echo $ai->agent()
->withTools([new PdoQuery()])
->run('How many active users do we have?');
// → "We have 1,247 active users." (after the agent ran SELECT count(*)…)
```
## Wie es funktioniert
```
user task
LLM gets system prompt with tool definitions
LLM emits ```tool {"tool":"pdo_query","args":{"sql":"SELECT…"}}```
Agent runs the tool, captures result
LLM gets observation, decides: more tools or final answer?
"FINAL: 1,247 active users."
```
Das Protokoll verwendet einen **gepufferten JSON-Sentinel**`\`\`\`tool {...}\`\`\`` — den jeder Modell erzeugen kann. Es ist keine native Tool-Aufruf-API erforderlich, daher funktioniert es auf jedem Ollama-Modell out of the box. (Modelle, die eine native Tool-Aufruf-API unterstützen, können über einen Subclass eingesetzt werden, der `parseToolCall()` überschreibt.)
## Eingebaute Tools
Nibiru bietet drei Produkte an:
| Werkzeug | Was es macht |
|---|---|
| `Tools\PdoQuery` | Einzelner schreibgeschützter `SELECT`-Befehl gegen die App-Datenbank. Blockiert INSERT/UPDATE/DELETE/DROP/TRUNCATE/ALTER. Gibt bis zu 50 Zeilen als JSON zurück. |
| `Tools\HttpGet` | Holt eine HTTP/HTTPS-URL mit optionalen Headern. Gibt den Body zurück, abgeschnitten auf 8 KB. |
| `Tools\FileRead` | Liest eine Projektdatei über einen relativen Pfad. Blockiert die `..`-Reise. Gibt bis zu 8 KB zurück. |
```php
use Nibiru\Module\Ai\Plugin\Tools;
$agent = $ai->agent()->withTools([
new Tools\PdoQuery(),
new Tools\HttpGet(),
new Tools\FileRead(),
]);
// Multi-step task
echo $agent->run(
'Read application/controller/loginController.php and tell me '
. 'whether it implements rate limiting.'
);
```
Der Agent wird `file_read` mit dem Pfad aufrufen, die Quelle beobachten und basierend auf dem tatsächlichen Gesehenen antworten nicht auf das, was es sich vorstellt.
## Ein benutzerdefiniertes Tool schreiben
Erweitern Sie `Tool`:
```php
namespace App\AiTools;
use Nibiru\Module\Ai\Plugin\Tool;
class StripeRefund extends Tool
{
public function name(): string { return 'stripe_refund'; }
public function description(): string {
return 'Issue a Stripe refund for a charge ID.';
}
public function schema(): array {
return [
'charge_id' => [
'type' => 'string',
'description' => 'A Stripe charge ID, e.g. ch_3K…',
'required' => true,
],
'amount_cents' => [
'type' => 'integer',
'description' => 'Amount to refund in cents. Omit for full refund.',
'required' => false,
],
];
}
public function execute(array $args): mixed {
$stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY'));
$refund = $stripe->refunds->create(array_filter([
'charge' => $args['charge_id'],
'amount' => $args['amount_cents'] ?? null,
]));
return json_encode([
'refund_id' => $refund->id,
'status' => $refund->status,
'amount' => $refund->amount,
]);
}
}
```
Dann stecken Sie es ein:
```php
$ai->agent()
->withTools([new \App\AiTools\StripeRefund(), new Tools\PdoQuery()])
->run('Refund order #4421 — they were charged twice.');
```
Der Agent wird `pdo_query` verwenden, um die Gebühr zu finden, und dann `stripe_refund` mit dieser Gebührs-ID aufrufen.
## Den Trace betrachten
```php
$agent = $ai->agent()->withTools([new Tools\PdoQuery()]);
$answer = $agent->run('How many products in the gold-plating category?');
foreach ($agent->trace() as $step) {
echo "Step {$step['step']}: action={$step['action']}\n obs={$step['observation']}\n";
}
```
Nützlich für das Debuggen, die Nachverfolgung von Abläufen oder zum Erstellen einer "Zeige dein Arbeitsvorgehen"-Benutzeroberfläche.
## Sicherheit
- **`PdoQuery` blocks writes.** Wenn Sie Schreibzugriff benötigen, erstellen Sie eine subklasse mit erhöhten Rechten und einer Überwachungsnachverfolgung. Heben Sie die SELECT-nur-Einschränkung im eingebauten Tool nicht auf.
- **`HttpGet` allows any URL by default.** Sperren Sie es über eine Zulassungsliste in `[AI] http_allowed_hosts[]` (geplant) oder erstellen Sie eine `RestrictedHttpGet`-Subklasse, die URLs filtert.
- **`FileRead` blocks `..`.** Es ist auf den Anwendungsstamm beschränkt.
- **Max iterations.** `agent.max_iterations = 6` in der INI verhindert unkontrollierte Schleifen. Erhöhen Sie vorsichtig.
- **Tool timeout.** `agent.tool_timeout = 30` (Sekunden). Ein Tool, das hängt, wird die Anfrage nicht ewig blockieren.
## Häufige Fallen
- **Vergessen Sie `withTools()`.** Ohne Werkzeuge ist der Agent nur ein regulärer `Chat`.
- **Lassen Sie dem Agenten Geheimnisse sehen.** Legen Sie niemals API-Schlüssel, rohe Passwörter oder PII in die Antwort eines Tools — das Modell erhält den vollständigen String.
- **Lange Werkzeugausgaben.** Jede Beobachtung wird an die Konversation angehängt. Ein Tool, das 50 KB ausgibt, erschöpft schnell den Kontext. Die eingebaute Tools haben eine Obergrenze von 8 KB; tun Sie das gleiche in Ihren benutzerdefinierten Tools.
- **Kein Werkzeugaufruf in der Antwort = endgültige Antwort.** Wenn das Modell eine endgültige Antwort produziert, die *aussehen* wie einen Werkzeugaufruf, aber nicht validiert wird, behandelt der Agent es als endgültig. Seien Sie explizit im Prompt: "Geben Sie einen Werkzeugaufruf ODER eine endgültige Antwort aus, niemals beides."

View File

@@ -0,0 +1,104 @@
---
title: "Chat-Plugin"
description: "Einzel- oder mehrstufige Chat-Vervollständigungen gegen einen beliebigen Ollama-kompatiblen Endpunkt."
---
Das Chat-Plugin ist das einfachste Element des KI-Moduls. Es umhüllt Ollamas `/api/chat` mit einem flüssigen Builder, einer Konversationsmemory, einem automatischen Fallback auf ein Sicherheitsmodell und einem `ask()`-Shortcut für einen Einzeiler.
## API im Überblick
```php
$ai = new \Nibiru\Module\Ai\Ai();
$chat = $ai->chat();
$chat->system('Be terse.'); // optional system prompt
$chat->model('qwen2.5-coder:14b'); // override the configured model
$chat->temperature(0.2); // override config
$chat->maxTokens(512); // override config
$chat->user('Hello'); // append a user message
$chat->assistant('Hi.'); // append an assistant message (rare)
$reply = $chat->complete(); // run the call, return text
$reply = $chat->ask('How are you?'); // = ->user(...)->complete()
$chat->reset(); // clear messages, keep model + system
$chat->history(); // [{role, content}, …]
```
## Einmalig
```php
echo (new \Nibiru\Module\Ai\Ai())
->chat()
->ask('In one sentence, what does Form::create() do?');
```
## Mehrfachgespräch
```php
$chat = $ai->chat();
$chat->user('Name three Nibiru singletons.');
$singletons = $chat->complete(); // appended to history
$chat->user('What does the second one do?');
$detail = $chat->complete(); // model has full context
```
## Überschreiben des Modells und des Stils pro Aufruf
```php
$german = $ai->chat()
->system('Answer in German. Be precise.')
->model('qwen2.5-coder:14b')
->temperature(0.1)
->ask('Wie definiere ich einen Controller?');
```
## Automatischer Fallback
Wenn `chat.model` (z.B. `nibiru-coder:1.0`) nicht auf dem Ollama-Server verfügbar ist, führt das Plugin den Aufruf mit `chat.fallback_model` (z.B. `qwen2.5-coder:14b`) erneut aus. Dies gewährleistet, dass Ihre Anwendung weiterhin funktioniert, während Sie die Feinabstimmung erstellen.
```ini
[AI]
chat.model = "nibiru-coder:1.0"
chat.fallback_model = "qwen2.5-coder:14b"
```
## Anbieter wechseln
Standard: Ollama. Um Anthropic Claude als Backend zu verwenden:
```ini
[AI]
chat.provider = "anthropic"
anthropic.api_key = "sk-ant-..."
anthropic.model = "claude-haiku-4-5-20251001"
```
Der Chat-Plugin verfügt derzeit nicht über die Anthropic-Transport-Funktion im Framework-Modul — für jetzt ist das Muster in `scripts/lib/providers.mjs` der Dokumentationssite das Referenzmodell. (Siehe die [Roadmap](/en/ai/roadmap/).)
## Ein praktisches Muster: Chat als eine Aktion
```php
namespace Nibiru;
use Nibiru\Adapter\Controller;
use Nibiru\Module\Ai\Ai;
class supportController extends Controller
{
public function askAction(): void {
View::forwardToJsonHeader();
$q = trim($this->getPost('question', ''));
if ($q === '') {
View::assign(['data' => ['error' => 'question required']]);
return;
}
$reply = (new Ai())->chat()
->system('You are the Nibiru support assistant. Be brief.')
->ask($q);
View::assign(['data' => ['answer' => $reply]]);
}
public function pageAction(): void {}
public function navigationAction(): void {
JsonNavigation::getInstance()->loadJsonNavigationArray();
}
}
```
Sechs Zeilen plus Boilerplate und Sie haben einen durch Ihren eigenen Ollama gestützten AJAX-aufrufbaren KI-Endpunkt.
## Häufige Fallen
- **Vergessen Sie `complete()`.** Der fließende Builder führt nichts aus, bis `complete()` oder `ask()` aufgerufen wird.
- **Aufruf von `assistant()` zwischen Benutzerabläufen.** Es dient dazu, eine gespeicherte Konversation wiederzugeben und nicht für die normale Nutzung.
- **Lange Konversationen.** Jeder Ablauf sendet die vollständige Geschichte erneut. Verkürzen Sie dies mit `reset()` oder durch Schneiden von `history()`, wenn Sie den älteren Kontext nicht mehr benötigen.
- **Temperatur zu niedrig einstellen.** 0 macht das Modell steif; 0,30,5 ist der ideale Bereich für technische Antworten.

View File

@@ -0,0 +1,87 @@
---
title: "Einbetten des Plugins"
description: "Text in Vektoren umwandeln. Kosinusähnlichkeit. Kompakte Speicherung. Die Infrastruktur unter dem RAG-Plugin, die auch selbst nützlich ist."
---
Das Embed-Plugin ist ein dünner Wrapper um Ollama's `/api/embeddings` plus drei nützliche Hilfsmittel: Kosinusähnlichkeit, kompakte Base64-Packung und inverse Entpackung. Das [RAG-Plugin](/en/ai/module/rag/) verwendet es intern, ist aber auch selbstständig hilfreich für Clustering, Deduplikation, semantische Suche und Anomalieerkennung.
## API
```php
$embed = (new \Nibiru\Module\Ai\Ai())->embed();
$vec = $embed->one('controller'); // float[]
$vectors = $embed->batch(['a', 'b', 'c']); // float[][]
$score = \Nibiru\Module\Ai\Plugin\Embed::cosine($a, $b); // 0..1
$packed = \Nibiru\Module\Ai\Plugin\Embed::pack($vec); // base64 string
$vec = \Nibiru\Module\Ai\Plugin\Embed::unpack($packed); // back to float[]
```
## Muster: Entfernen ähnlicher Zeichenfolgen
```php
$embed = $ai->embed();
$candidates = ['How do I create a module?',
'How can I make a new module?',
'What is MMVC?'];
$vecs = $embed->batch($candidates);
foreach ($vecs as $i => $a) {
foreach ($vecs as $j => $b) {
if ($i >= $j) continue;
$sim = \Nibiru\Module\Ai\Plugin\Embed::cosine($a, $b);
if ($sim > 0.9) {
echo "Near-dup: {$candidates[$i]}{$candidates[$j]} ($sim)\n";
}
}
}
```
## Muster: semantische Markierung
```php
$tags = ['authentication', 'forms', 'database', 'modules'];
$tagVecs = array_combine($tags, $embed->batch($tags));
function bestTag(string $text, array $tagVecs, $embed): string {
$tv = $embed->one($text);
$best = ['_unknown', -INF];
foreach ($tagVecs as $tag => $vec) {
$s = \Nibiru\Module\Ai\Plugin\Embed::cosine($tv, $vec);
if ($s > $best[1]) $best = [$tag, $s];
}
return $best[0];
}
echo bestTag('User::isAuthorized', $tagVecs, $embed); // → 'authentication'
echo bestTag('Pageination::setTable', $tagVecs, $embed); // → 'database' (probably)
```
## Speicherung
Einbettungen sind Gleitkommazahlenarrays typischerweise 768 Gleitkommazahlen für `nomic-embed-text` und 1024 für `mxbai-embed-large`. Das entspricht 3 KB oder 4 KB pro Vektor im Rohformat.
Verwenden Sie `Embed::pack()`, um sie als 4-Byte-Gleitkommazahlen in Base64 zu kodieren:
```php
$compact = Embed::pack($vec); // ~4 KB → ~5.3 KB base64 string
$vec = Embed::unpack($compact);
```
Das RAG-Plugin verwendet dieses Format intern für seine JSON-Dateien.
## Auswahl der eingebetteten Modelle
Ziehen Sie auf neuronetz.ai einmal:
```bash
curl https://api.neuronetz.ai/api/pull -d '{"name":"nomic-embed-text"}' # 768 dim, default
curl https://api.neuronetz.ai/api/pull -d '{"name":"mxbai-embed-large"}' # 1024 dim, higher quality
```
In `ai.ini`:
```ini
[AI]
embed.model = "nomic-embed-text"
embed.dim = 768
```
:::caution[Kombination von Modellen vermeiden]
Vektoren aus `nomic-embed-text` und `mxbai-embed-large` befinden sich in unterschiedlichen geometrischen Räumen — der Kosinus zwischen ihnen ist ohne Bedeutung. Wenn Sie `embed.model` ändern, **setzen** Sie vor der Kombination jede RAG-Sammlung oder gespeicherten Embeddings zurück.
:::
## Häufige Fallen
- **Aufrufen von `one()` in einer engen Schleife.** Jeder Aufruf ist eine HTTP-Roundtrip. Für >100 Elemente bevorzugen Sie `batch()` (immer noch seriell unter der Haube, aber mit konsistenter Fehlerbehandlung).
- **Speichern roher Gleitkommazahlenarrays in JSON.** Verwenden Sie `pack()` für etwa 5-fach kleinere Dateien und schnellere Analyse.
- **Vergleichen von Kosinus zu einem festen Schwellenwert.** Verschiedene Einbettungsmodelle haben unterschiedliche "ähnliche" Baseline-Werte. Vermeiden Sie die Festlegung auf 0,85 kalibrieren Sie es je nach Modell.

View File

@@ -0,0 +1,160 @@
---
title: "Das KI-Modul"
description: "Erster-Klasse KI auf Nibiru Chat, Embeddings, RAG, Agenten verbunden mit Ihrem eigenen Ollama auf neuronetz.ai. Keine bezahlten APIs erforderlich."
---
Nibiru bietet ein **KIM-Modul** (`application/module/ai/`), das jeder Nibiru-Anwendung eine erstklassige KI-Oberfläche gibt. PHP-Code kann mit einem lokalen LLM chatten, Text einbetten, RAG über eigene Daten ausführen oder einen Agenten mit Tools starten alles ohne einen Byte an eine bezahlte API zu senden.
Das Modul ist standardmäßig mit Ihrem eigenen [Ollama auf neuronetz.ai](/de/kuenstliche-intelligenz/orakel/) verbunden, sodass die Inferenz auf Ihrer Hardware, in Ihrem Netzwerk und Ihren Bedingungen erfolgt.
## Was Sie erhalten
| Plugin | Was es macht | Einzeiler |
|---|---|---|
| `Chat` | Chat-Vervollständigungen, ein- oder mehrfach | `$ai->chat()->ask('…')` |
| `Embed` | Text → Vektoren + Kosinus-Hilfsprogramme | `$ai->embed()->one('…')` |
| `Rag` | Aufnehmen + Abrufen + angebundener Chat | `$ai->rag('docs')->ask('…')` |
| `Agent` | Werkzeugnutzender ReAct-Schleife | `$ai->agent()->withTools([…])->run('…')` |
| `Tool` | Grundlage für Ihre eigenen benutzerdefinierten Tools | `class MyTool extends Tool { … }` |
| `Ollama` | Roher HTTP-Transport zu jedem Ollama-kompatiblen Endpunkt | `(new Ollama($cfg))->chat(…)` |
## Hallo, KI
```php
use Nibiru\Module\Ai\Ai;
$ai = new Ai();
echo $ai->chat()->ask('How do I scaffold a new module?');
// → "Run `./nibiru -m <name>`. This creates application/module/<name>/ with…"
```
Dies ist die gesamte API-Oberfläche für den einfachen Fall. Kein Dependency Injection-Container, keine API-Schlüssel, kein SDK-Installieren.
## Über Konfiguration verbunden
Jedes Plugin liest seine Einstellungen aus `application/module/ai/settings/ai.ini`:
```ini
[AI]
ollama.base_url = "https://api.neuronetz.ai"
chat.model = "nibiru-coder:1.0"
chat.fallback_model = "qwen2.5-coder:14b"
chat.temperature = 0.4
chat.max_tokens = 1024
embed.model = "nomic-embed-text"
rag.top_k = 6
agent.max_iterations = 6
```
Umgebungsspezifische Überschreibungen: `ai.production.ini`, `ai.staging.ini`. Der Nibiru-Registrierungsmechanismus erkennt diese automatisch.
## Die vier Hauptanwendungsfälle
### 1. Chat — sprechen Sie mit Ihrem Modell
```php
$ai = new \Nibiru\Module\Ai\Ai();
// One-shot
echo $ai->chat()->ask('Explain MMVC in two sentences.');
// Multi-turn
$chat = $ai->chat();
$chat->user('How do I scaffold a module?');
$chat->user('And add Graylog hooks?'); // referrs to previous turn
echo $chat->complete();
// Override per call
echo $ai->chat()
->system('Answer in German.')
->model('qwen2.5-coder:14b')
->temperature(0.1)
->ask('Was ist ein Modul?');
```
Der `Chat`-Plugin wechselt automatisch zu `chat.fallback_model`, wenn das primäre Modell nicht verfügbar ist nützlich, während Sie noch an `nibiru-coder` arbeiten.
### 2. Einbetten — Text in Vektoren
```php
$embed = $ai->embed();
$va = $embed->one('controller');
$vb = $embed->one('module');
$score = \Nibiru\Module\Ai\Plugin\Embed::cosine($va, $vb);
// → 0.78 (close concepts)
```
Kompakte Speicherung:
```php
$packed = \Nibiru\Module\Ai\Plugin\Embed::pack($vec); // base64 string, 4 bytes/dim
$vec = \Nibiru\Module\Ai\Plugin\Embed::unpack($packed);
```
### 3. RAG — Ingestieren, Abrufen, Verankern
```php
$rag = $ai->rag('product-help');
// One-time ingestion
$rag->ingestDir(__DIR__ . '/help/'); // walks .md/.txt/.php
$rag->ingestText('FAQ entry…', ['source' => 'faq-12']);
$rag->ingestFile('/var/data/manual.pdf.txt');
// Then ask grounded questions
echo $rag->ask('How do I cancel my subscription?');
// → "Per the help docs, you can cancel in account → settings… [1]"
```
Speicherung: Eine einzelne JSON-Datei pro Sammlung unter `application/module/ai/cache/rag/<name>.json`. Wiederholbar, ohne Datenbank, passt bequem ~10.000 Chunks im Speicher.
### 4. Agent — Werkzeuge, die handeln
```php
use Nibiru\Module\Ai\Plugin\Tools;
$ai = new \Nibiru\Module\Ai\Ai();
$agent = $ai->agent()->withTools([
new Tools\PdoQuery(), // read-only SQL
new Tools\HttpGet(), // fetch URLs
new Tools\FileRead(), // read project files
]);
echo $agent->run('How many active users registered last week?');
// → agent decides to call pdo_query with SELECT count(*) FROM users…
// reads observation, writes a final answer.
```
Der Agent verwendet eine ReAct-stilige Schleife: Lesen der Aufgabe → Auswahl des Tools → Ausführen → Beobachten → Wiederholen → Endantwort. Das Protokoll verwendet einen einfachen JSON-Sentinel `\`\`\`tool {...}\`\`\`` , der bei jedem Ollama-Modell funktioniert keine modellspezifischen Tool-Aufruf-APIs erforderlich.
## Wo es lebt
```
application/module/ai/
├── ai.php # main class implementing IModule
├── interfaces/ai.php # contract
├── traits/ai.php # cfg() helper
├── plugins/
│ ├── ollama.php # raw transport
│ ├── chat.php # chat completions
│ ├── embed.php # embeddings + cosine + pack
│ ├── rag.php # ingest + retrieve + grounded chat
│ ├── agent.php # ReAct tool loop
│ ├── tool.php # abstract base for custom tools
│ └── tools/
│ ├── pdoQuery.php # read-only SQL
│ ├── httpGet.php # HTTP GET
│ └── fileRead.php # project-local file read
├── settings/ai.ini # config
├── cache/rag/ # RAG vector index files (gitignored)
└── training/
├── Modelfile # the nibiru-coder system prompt
├── build.sh # one-command Modelfile → registered model
├── smoke-test.php # verify the whole stack
└── README.md # training pipeline guide
```
## Warum dies existiert
PHP hat keine etablierte "Künstliche-Intelligenz-Frameworks" wie Python mit LangChain oder JS mit Vercel AI SDK. Das Nibiru-AI-Modul füllt diesen Lücke mit der kleinsten und schärfsten API, die wir schreiben konnten drei Ebenen (Transport → Plugin → Modul), kein DI-Grafik, keine SDK-Installation, keine pro-Token-Rechnung.
Die Gestaltungsentfernung:
- **Bring your own brain.** Ollama ist standardmäßig aktiviert, Anthropic und OpenAI können als Ersatzmodule verwendet werden. Providerwechsel erfolgt über INI-Dateien, nicht über den Code.
- **Eine JSON-Datei pro RAG-Sammlung.** Keine Vektordatenbank erforderlich. Wiederherstellungsicher nach einem Neustart. Grep-fähig bei der Fehlerbehebung.
- **Tools sind PHP-Klassen.** Erweitern Sie `Tool`, erhalten Sie einen Namen + Schema + eine Ausführungsmethode. Der Agent kümmert sich um den Rest.
- **Keine modellspezifischen Tool-Aufruf-APIs.** Eine einzige eingerahmte JSON-Konvention funktioniert überall.
## Nächste
- [Chat-Plugin-Referenz](/de/kI/modul/chat/)
- [RAG-Plugin-Referenz](/de/kI/modul/rag/)
- [Agent-Plugin-Referenz](/de/kI/modul/agent/)
- [Training nibiru-coder](/de/kI/modul/training/)

View File

@@ -0,0 +1,99 @@
---
title: "RAG-Erweiterung"
description: "Text einlesen, eingebettet, Top-K abrufen und fundierte Fragen beantworten alles in einer PHP-Klasse."
---
Das RAG-Plugin ist die Killerfunktion des KI-Moduls für Produktbauer. Es verwandelt jede Menge Text Ihre Hilfedokumentationen, Ihre Fehlerprotokolle, Ihre Stripe-Rechnungen, Ihre Kundensupport-Tickets in eine abfragbare Wissensbasis in etwa vier Zeilen PHP.
## Drei Minuten, von Anfang bis Ende
```php
use Nibiru\Module\Ai\Ai;
$ai = new Ai();
$rag = $ai->rag('product-help'); // a named collection
$rag->ingestDir(__DIR__ . '/help/'); // walks .md/.txt/.php under help/
$rag->ingestText('FAQ entry…', ['source' => 'faq-12']);
echo $rag->ask('How do I cancel my subscription?');
// → grounded answer, citing chunks like [1] [2] [3]
```
Das ist alles. Keine Vektordatenbank. Kein SDK. Kein Python-Sidecar.
## Wie es funktioniert
```
ingestText / ingestFile / ingestDir
chunk → embed (Ollama nomic-embed-text)
pack vectors → JSON file at cache/rag/<collection>.json
ask(question) → embed question → cosine top-K → chat with chunks as context
```
Speicherung erfolgt durch eine JSON-Datei pro Sammlung. Jeder Chunk ist ein Objekt mit `text` und `metadata`; Vektoren sind als base64-gepackte Float32Array gespeichert etwa 3 KB pro Chunk. ~10.000 Chunks passen komfortabel in den Speicher.
## Mehrere Sammlungen
Sie können eine beliebige Anzahl von Sammlungen in der gleichen App haben. Jede hat ihre eigene JSON-Datei. Sie teilen das Einbettungsmodell und das Chatmodell aus der `[AI]`-Konfiguration.
```php
$docs = $ai->rag('docs');
$tickets = $ai->rag('support-tickets');
$logs = $ai->rag('error-logs');
$docs->ingestDir(__DIR__ . '/help/');
$tickets->ingestText($ticket->body, ['ticket_id' => $ticket->id]);
$logs->ingestText($exception->__toString(), ['ts' => time()]);
```
## API-Referenz
```php
$rag = $ai->rag('name'); // get/create a named collection
// --- Ingestion ---
$rag->ingestText($text, $metadata = []); // single chunk
$count = $rag->ingestFile('path'); // returns chunks added
$count = $rag->ingestDir('dir', ['md','txt','php']); // recursive
// --- Querying ---
$hits = $rag->search('query', $k = null); // [{score, text, metadata}, …]
$answer = $rag->ask('question', $k = null); // top-K → chat call
// --- Maintenance ---
$rag->reset(); // forget everything (deletes file)
$n = $rag->size(); // number of chunks
```
## Einstellungsregler
In `application/module/ai/settings/ai.ini`:
```ini
[AI]
embed.model = "nomic-embed-text" ; or mxbai-embed-large for higher quality
rag.top_k = 6 ; chunks injected into the chat call
rag.chunk_target = 600 ; tokens per chunk (target)
rag.chunk_min = 120 ; smaller chunks merged
rag.chunk_max = 900 ; larger paragraphs split on sentences
rag.storage_path = "/../../application/module/ai/cache/rag/"
```
## Wann es verwendet werden sollte
- **Hilfe / FAQ Chat** — Laden Sie Ihre Hilfesätze ein und stellen Sie einen `/ask` Endpunkt zur Verfügung.
- **In-app Code-Suche** — Laden Sie `application/module/` ein und fragen Sie sich "Wo berechnen wir die Mehrwertsteuer?"
- **Assistent für interne Dokumente** — Laden Sie den Wiki-Dump Ihres Teams ein.
- **Kundengeschichtensuchungen** — Laden Sie Tickets ein und fragen Sie sich "Haben wir diesen Fehler schon einmal gesehen?"
## Wann es NICHT verwendet werden sollte
- **Echtzeit, schreibintensive Daten** — RAG ist ein Snapshot. Für lebende Daten schreiben Sie ein [Tool](/en/ai/module/agent/), das der Agent aufrufen kann.
- **Massive Korpora (> 100k Chunks)** — Die Speicherung in JSON-Dateien beginnt zu knarzen. Wechseln Sie zu Qdrant / pgvector / Weaviate; wir veröffentlichen einen Adapter, sobald wir einen für uns selbst benötigen.
- **Alles, wo Sie *genaue* Antworten benötigen und nicht nur *wahrscheinliche* Antworten.** RAG ist probabilistisch. Verwenden Sie es nicht als Datenbankabfrageebene.
## Häufige Fallen
- **`nomic-embed-text` wurde nicht abgerufen.** Der erste Aufruf von `ingestText` schlägt mit einem klaren Fehler fehl, der Sie auf den Pull-Befehl hinweist.
- **Modellkonflikt beim Einbetten.** Verwenden Sie keine `nomic-embed-text`-Blöcke zusammen mit `mxbai-embed-large`-Abfragen unterschiedliche Vektorräume. Wenn Sie `embed.model` ändern, führen Sie zuerst `$rag->reset()` aus.
- **Veraltete Sammlungen.** Das erneute Ausführen von `ingestDir` entfernt keine Duplikate. Verwenden Sie `reset()` und fügen Sie dann erneut ein, oder überprüfen Sie selbst eine Inhalts-Hash-Erkennung.
- **Kleine Blöcke.** Unter etwa 80 Token werden die Einbettungen störend. Der Standardwert von `rag.chunk_min = 120` führt kleine benachbarte Blöcke zusammen.
## Was kommt als nächstes?
- [Agent-Plugin →](/de/kI/modul/agent/) für Werkzeuge, nicht für Abruf.
- [Trainingsnibiru-coder →](/de/kI/modul/trainings/) um den Chat so zu gestalten, dass er halb in der Stimme des Frameworks antwortet.

View File

@@ -0,0 +1,111 @@
---
title: "Training nibiru-coder"
description: "Wie Sie ein Nibiru-gesmacktes Chatmodell auf Ihrem eigenen Ollama registrieren. Eine Modelldatei, ein Shell-Skript, sechzig Sekunden."
---
Das Standard-Chatmodell des Frameworks ist **`nibiru-coder:1.0`** — ein Nibiru-geschnittenes Qwen 2.5 Coder 14B, das Sie auf Ihrem Ollama-Server registrieren. Der Trainingspipeline befindet sich im Verzeichnis `application/module/ai/training/`.
## Was `nibiru-coder` ist (und nicht ist)
`nibiru-coder:1.0` ist **kein** LoRA Fine-Tuning. Es sind die gleichen Gewichte von `qwen2.5-coder:14b`, verpackt mit einem eingebauten System-Prompt, der:
- erläutert MMVC, Module, den Dispatcher und die Singletons,
- erzwingt die Konventionen von Nibiru (`pageAction`, `navigationAction`, `View::assign`, `Form::create`, die Schreibweise von `Pageination`),
- fördert das Modell zu Nibiru-idiomatischen Antworten anstelle allgemeiner Laravel / Symfony-Ratschläge.
Die Anpassung des System-Prompts erfolgt **unmittelbar** — keine GPU-Training, keine Datensatz-Vorbereitung erforderlich. Es bietet ungefähr 80 % der Wertschöpfung einer echten LoRA bei null Trainingskosten. Wenn Sie ein Budget für eine echte Feinabstimmung haben, siehe unten *Echte LoRA-Pfad*.
## Bauen Sie es
```bash
./application/module/ai/training/build.sh # builds nibiru-coder:1.0
./application/module/ai/training/build.sh 1.1 # bump tag for iterations
```
Das Skript:
1. Liest die Datei `Modelfile` neben sich.
2. Sendet eine POST-Anfrage an `${OLLAMA_BASE_URL}/api/create` (Standardwert `https://api.neuronetz.ai`).
3. Führt einen Rauchtest-Chat-Aufruf durch, um zu bestätigen, dass der neue Tag antwortet.
Nachdem es erfolgreich ist, setzen Sie das Modell in `application/module/ai/settings/ai.ini`:
```ini
[AI]
chat.model = "nibiru-coder:1.0"
chat.fallback_model = "qwen2.5-coder:14b"
```
…und jede Instanz von `\Nibiru\Module\Ai\Ai` in Ihrer Anwendung kommuniziert mit ihr. Der Fallback stellt sicher, dass nichts bricht, wenn Sie den Tag noch nicht erstellt haben.
## Arbeiten Sie an dem System-Prompt weiter
Der `SYSTEM """ ... """`-Block im Modelfile ist der Schlüssel. Verfestigen Sie die Konventionen, fügen Sie neue Beispiele hinzu und verweisen Sie auf spezifische Framework-Dateien. Führen Sie `build.sh` erneut mit einem neuen Tag (`1.1`, `1.2`, …) aus und führen Sie eine A/B-Vergleichsphase im Vergleich zum vorherigen Tag in Ihrer Anwendung durch.
```bash
./application/module/ai/training/build.sh 1.1
# Edit ai.ini → chat.model = "nibiru-coder:1.1"
# Compare answers in the Oracle widget or via smoke-test.php
```
## Echte LoRA-Pfad
Wenn Sie ein Modell benötigen, dessen **Gewichte** Nibiru kennen nicht nur sein System-Prompt bietet Ihnen der Korpus-Exporter die Lösung.
```bash
cd docs
npm run build:corpus
```
Generiert JSONL-Dateien unter `dist/corpus/`:
| Datei | Format | Verwendung |
|---|---|---|
| `chat.jsonl` | sharegpt-stilige Nachrichten | Axolotl, LLaMA-Factory, Unsloth |
| `instructions.jsonl` | Anweisung/Eingabe/Ausgabe | Alpaca-artige Trainer |
| `completion.jsonl` | Aufforderung/Ergebnis | Legacy Textabschluss Feinabstimmungen |
| `chunks.jsonl` | Chunk-Metadaten | RAG / Evaluierungsset-Konstruktion |
Ein praktisches Rezept für eine 8B-Basis auf einem einzelnen A100 / 4090:
```yaml
# axolotl.yml
base_model: meta-llama/Llama-3.1-8B-Instruct
adapter: lora
lora_r: 16
lora_alpha: 32
lora_dropout: 0.05
lora_target_modules: [q_proj, k_proj, v_proj, o_proj]
datasets:
- path: docs/dist/corpus/chat.jsonl
type: sharegpt
sequence_len: 4096
sample_packing: true
gradient_accumulation_steps: 4
micro_batch_size: 2
num_epochs: 3
optimizer: adamw_bnb_8bit
learning_rate: 0.0002
warmup_ratio: 0.05
bf16: true
```
Nach dem Training:
1. Konvertieren Sie das LoRA in GGUF (`llama.cpp`'s `convert_hf_to_gguf.py`).
2. Erstellen Sie eine Ollama Modelfile mit `FROM ./your-lora.gguf`.
3. Führen Sie `./build.sh 2.0` aus, um es als `nibiru-coder:2.0` zu registrieren.
Der Framework-Code ändert sich nicht — ändern Sie `chat.model` in `ai.ini`, und Sie verwenden die neuen Gewichte.
## Rauchtest
```bash
php application/module/ai/training/smoke-test.php
```
Überprüft:
- Der Ollama-Server ist erreichbar.
- Das Modell reagiert auf eine Einzelschritt-Anfrage.
- Mehrschrittige Konversationskontexte funktionieren.
- Embeddings funktionieren (wenn `nomic-embed-text` nicht gepullt wird, wird eine klare Nachricht ausgegeben).
Führen Sie nach jeder Änderung der Datei `Modelfile` aus, bevor Sie bereitstellen.
## Häufige Fallen
- **`Modelfile` System-Prompt ist zu lang.** Einige Ollama-Versionen begrenzen System-Prompts. Halten Sie es unter etwa 3000 Token.
- **Vergessen des FROM-Modells.** `qwen2.5-coder:14b` muss bereits auf dem Server vorhanden sein. Überprüfen Sie mit `curl ${OLLAMA_BASE_URL}/api/tags`.
- **Tag-Kollisionen.** Das erneute Ausführen von `build.sh 1.0` überschreibt das bestehende `nibiru-coder:1.0`. Verwenden Sie für Iteration neue Tags; fixieren Sie spezifische Tags in `ai.ini` für die Produktion.
- **Verwirrung mit `--no-stream`.** Das Build-Skript verwendet `stream: false`, sodass die Antwort als ein JSON zurückkommt. Wenn Sie zu einem gestreamten Modus wechseln, analysieren Sie Zeile für Zeile.

View File

@@ -0,0 +1,112 @@
---
title: "Frage die Oracle"
description: "Wie der eingebettete KI-Assistent funktioniert RAG über die Dokumentationen, bereitgestellt durch Ihren eigenen Ollama auf neuronetz.ai."
---
Die orange Schaltfläche in der Ecke jeder Seite ist der **Nibiru Oracle** ein KI-Assistent, der auf dieser Dokumentation basiert. Fragen Sie ihn nach Routing, Modulen, der CLI, der Smarty-Schicht oder der Bedeutung von `pageAction()`. Er zitiert seine Quellen.
## Was steuert es
Standardmäßig läuft der Oracle vollständig auf **Ihren eigenen Infrastrukturen**.
| Ebene | Backend | Standardmodell |
|---|---|---|
| Chat (Antwortgenerierung) | Ollama auf `https://api.neuronetz.ai` | `qwen2.5-coder:14b` |
| Embeddings (RAG-Retrieval) | Ollama auf `https://api.neuronetz.ai` | `nomic-embed-text` |
Keine bezahlten API-Schlüssel. Ihre Daten verlassen Ihr Netzwerk nicht. Der 5-GPU-Cluster von Ollama, den Sie bereits betreiben, übernimmt die Last.
Wenn Sie lieber einen bezahlten Anbieter verwenden möchten Claude für Chats und OpenAI für Embeddings setzen Sie `LLM_PROVIDER=anthropic` und/oder `EMBED_PROVIDER=openai` sowie die entsprechenden API-Schlüssel. Die Codepfade sind identisch.
## Wie es funktioniert
```mermaid
flowchart LR
A[User question] --> B[Embed via Ollama<br/>nomic-embed-text]
B --> C[Cosine search<br/>against pre-computed<br/>doc-chunk index]
C --> D[Top-K chunks]
D --> E[Ollama chat<br/>qwen2.5-coder:14b<br/>system + retrieved context]
E --> F[Answer + source list]
F --> G[Render in chat UI]
```
1. **Beim Build-Zeitpunkt** durchläuft die Dokumentationssite jede Markdown-Seite, teilt sie an den Grenzen von H2/H3 in etwa 600-Token-Blöcken auf, bettet jedes Blatt mit `nomic-embed-text` ein und schreibt das Ergebnis in `public/oracle-index.json`. Keine Datenbank erforderlich.
2. **Beim Anfrage-Zeitpunkt** wird die Frage des Benutzers auf dieselbe Weise eingebettet, die nächsten Blöcke werden durch Kosinusähnlichkeit abgerufen und zu einem System-Prompt für das Chat-Modell zusammengefügt.
3. **Antwortet das Chat-Modell** im Sprach des Benutzers und bezieht sich auf die Quellenblöcke per URL.
## Dateien
| Datei | Zweck |
|---|---|
| `scripts/lib/providers.mjs` | Gemeinsamer Chat- und Embedding-Adapter (Ollama / Anthropic / OpenAI). |
| `scripts/build-oracle-index.mjs` | Erstellt `public/oracle-index.json` zur Build-Zeit. |
| `public/oracle-index.json` | Der eingeführte/Build-Ausgabe-Embedding-Index. |
| `src/pages/api/oracle.ts` | Der SSR-Endpunkt, an den das Chat-Widget POSTs. Bietet auch einen GET für Diagnosen. |
| `src/components/CosmicHeader.astro` | Der schwebende Launcher + Chat-Benutzeroberfläche. |
## Einmalige Einrichtung auf neuronetz.ai
Ziehen Sie die beiden Modelle herunter, die der Oracle verwendet:
```bash
curl https://api.neuronetz.ai/api/pull -d '{"name":"qwen2.5-coder:14b"}'
curl https://api.neuronetz.ai/api/pull -d '{"name":"nomic-embed-text"}'
```
`qwen2.5-coder:14b` ist bereits installiert (lebt getestet). `nomic-embed-text` fehlt noch; ohne es läuft der Oracle im Chat-Modus (ohne RAG).
## Konfiguration durchführen
Der Oracle liest seine Konfiguration aus Umgebungsvariablen. Sinnvolle Standardwerte sind integriert.
```bash
# Default mode (Ollama on neuronetz.ai)
LLM_PROVIDER=ollama # default
OLLAMA_BASE_URL=https://api.neuronetz.ai # default
OLLAMA_CHAT_MODEL=qwen2.5-coder:14b # default
OLLAMA_EMBED_MODEL=nomic-embed-text # default
# Optional fallbacks
LLM_PROVIDER=anthropic
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
EMBED_PROVIDER=openai
OPENAI_API_KEY=sk-...
OPENAI_EMBED_MODEL=text-embedding-3-small
# Behaviour
ORACLE_TOP_K=6
ORACLE_MAX_TOKENS=800
```
## Diagnose-Endpunkt
`GET /api/oracle` gibt die aktuelle Konfiguration zurück (keine Geheimnisse):
```bash
curl https://nibiru-framework.com/api/oracle
{
"status": "ok",
"llm": { "provider": "ollama", "ollamaUrl": "https://api.neuronetz.ai",
"model": "qwen2.5-coder:14b" },
"embed": { "provider": "ollama", "ollamaUrl": "https://api.neuronetz.ai",
"model": "nomic-embed-text" },
"index": { "present": true, "chunks": 177,
"provider": "ollama", "model": "nomic-embed-text" }
}
```
Nützlich zur Überprüfung, ob ein neu bereitgestellter Container den Back-End-Dienst verwendet, den Sie erwartet haben.
## Datenschutz
- Fragen und Gesprächshistorie werden an Ihren Ollama-Server gesendet. Sie werden **nicht** vom Docs-Site oder von Anthropic/OpenAI im Standard-Ollama-Konfiguration gespeichert.
- Der OpenAI-Schlüssel (falls verwendet) wird nur für Embeddings aufgerufen.
- Das Oracle-Widget selbst setzt keine Analysen oder Cookies.
## Warum ein von Nibiru trainiertes Modell?
Die Roadmap (siehe [AI Roadmap](/en/ai/roadmap/)) besteht darin, eine LoRA auf der Exportdatei des [Trainingskorpus](/en/ai/corpus/) zu feinabzustimmen, sodass das Chatmodell selbst Nibiru-nativ ist. Sobald dies bereitgestellt ist, wird die `OLLAMA_CHAT_MODEL` des Orakels auf das feinabgestimmte Modell umgeschaltet und der Systemprompt vereinfacht. Gleiches Code, intelligentere Antworten.
## Try it
Öffnen Sie den Oracle (die gelbe Planeten, unten rechts) und versuchen Sie eines dieser Optionen:
- *"Wie erstelle ich ein neues Modul?"*
- *"Was macht `pageAction`?"*
- *"Zeige mir, wie ich einen JSON-Endpunkt verwalte."*
- *"Wie schreibe ich eine Migration?"* (Deutsch funktioniert.)
- *"Authentifizierungsfluss erläutern"* (Japanisch funktioniert.)

View File

@@ -0,0 +1,55 @@
---
title: "AI Roadmap"
description: "Wo die KI-Integration von Nibiru hingehört der Plan, uns vom auf RAG-basierenden Oracle zu einem fein abgestimmten LoRA in der Produktion zu bringen."
---
Nibiru's Ambition: Sei das **erste PHP-Framework mit einem fein abgestimmten Modell, das auf eigene Kenntnisse trainiert wurde**, das als erstklassiger Bestandteil der Entwicklererfahrung angeboten wird. Diese Seite verfolgt die Schritte.
## Phase 1 — Heute: RAG Oracle ✓
- [x] Markdown-Segmentierer mit H2/H3-Grenzen.
- [x] OpenAI-Einbettungen (`text-embedding-3-small`).
- [x] Vektorindex als einzelne JSON-Datei.
- [x] Astro-Endpunkt `/api/oracle`, der Claude mit abgeruftem Kontext aufruft.
- [x] Gleitender Chat-Widget auf jeder Dokumentationsseite.
- [x] Mehrsprachig (EN/DE/JA/ES/FR) Eingabe + Ausgabe.
**Warum zuerst.** RAG funktioniert ohne Training, skaliert linear mit der Inhaltsgröße und ist sehr günstig. Jede Dokumentenbearbeitung verbessert die Antwortqualität in derselben Stunde.
## Phase 2 — Nächster Schritt: Öffentlicher Korpus + LoRA Rezept
- [ ] `npm run build:corpus` wird in `main` bereitgestellt (Anleitungen/Chat/Ausschnitte JSONL).
- [ ] Veröffentlichung des Hugging Face-Datasets (`nibiru-framework/docs-corpus`).
- [ ] Referenzieren Sie die Axolotl-YAML für Llama 3.1 8B.
- [ ] Referenzieren Sie die Rezepte für Qwen 2.5 7B und Mistral Nemo 12B.
- [ ] Bewertungsmenge: 200 von Hand bewertete Nibiru-Fragen mit goldenen Antworten.
**Warum zweitens.** Sobald das Korpus aus den Dokumenten reproduzibel ist, kann jeder trainieren. Wir behandeln die Dokumente als Quelle der Wahrheit und das Korpus als abgeleitetes Artefakt.
## Phase 3 — Dann: Gehosteter LoRA-Endpunkt
- [ ] Trainen Sie einen ersten Durchgang von LoRA auf dem öffentlichen Korpus.
- [ ] Bereitstellen Sie über vLLM hinter `/api/oracle` mit einem Feature-Flag.
- [ ] Seite an Seite Benutzeroberfläche zum Vergleich zwischen Claude (RAG) und LoRA (ohne RAG) sowie LoRA + RAG.
- [ ] Telemetrie: Welches Formular bevorzugt der Benutzer je nach Frageart?
**Warum drittens.** Eine Seite-an-Seite-Vergleich zeigt, wo die LoRA hilft (idiomatischer Nibiru-Stil) und wo sie schadet (sehr langer Kontext, frische Bearbeitungen sind noch nicht neu trainiert).
## Phase 4 — Schließlich: Editor-Agents
- [ ] PHPStorm-Erweiterung: markieren Sie einen Controller und fragen Sie den Oracle, ihn in ein Modul umzuwandeln.
- [ ] CLI-Agent: `./nibiru ask "diese Kontroller als JSON-Endpunkt umschreiben"`.
- [ ] PR-Review-Bot: erklären Sie Nibiru-spezifische Abweichungen in Pull Requests auf Framework-Forks.
## Phase 5 — Aspirational: Aktives Lernen
- [ ] Benutzerfeedback im Chat-Widget (👍 / 👎) schreibt eine Zeile in ein privates Dataset.
- [ ] Die wöchentliche Überprüfungswarteschlange hebt niedrig bewertete Antworten für menschliche Anmerkungen hervor.
- [ ] Verbesserte Antworten gelangen wieder ins Korpus im nächsten Trainingszyklus.
## Wie Sie helfen können
- **Stellen Sie den Oracle schwierige Fragen** und bewerten Sie die Antworten.
- **Öffnen Sie Issues** im [GitHub Repo](https://github.com/alllinux/Nibiru) für fehlende Themen.
- **Tragen Sie Übersetzungen bei** — jede übersetzte Dokumentenseite ist auch eine Zeile des parallelen Korpus.
- **Probieren Sie eine LoRA Fine-Tuning** auf dem veröffentlichten Korpus und teilen Sie die Ergebnisse.

View File

@@ -0,0 +1,57 @@
---
title: "CMS-Seiten (CLI)"
description: "Erstellen und Löschen von CMS-Seiten über die Befehlszeile."
---
Wenn das `cms`-Modul installiert ist, erhält die Nibiru CLI zwei zusätzliche Flags für die direkte Verwaltung von CMS-Seiten. Dies ist der gleiche Inhaltsspeicher, der den Produktions-E-Commerce-Site `prod.maschinen-stockert.de` betreibt, wo Redakteure Site-Copy aktualisieren können, ohne Code zu berühren.
## Seite erstellen
```bash
./nibiru -new-cms-page about-us
```
Dies:
1. Fügt eine Zeile in die Tabelle `cms_pages` mit dem Slug `about-us` ein.
2. Bindet die Seite an eine CMS-Vorlage (die Standardvorlage, es sei denn, eine andere ist konfiguriert).
3. Erstellt pro Sprache Platzhalterzeilen in der Tabelle `cms_template_texts`, sodass Redakteure Texte in jeder unterstützten Sprache eingeben können.
Besuchen Sie `/cms/about-us` (oder Ihren konfigurierten CMS-Prefix) und die neue Seite ist aktiv.
## Seite löschen
```bash
./nibiru -delete-cms-page about-us
```
Entfernt die Seitenzeile und ihre zugehörigen `cms_template_texts` Einträge. Die eigentliche CMS-Vorlage wird nicht gelöscht nur die Verknüpfung der Seite mit ihr.
## Warum diese Befehle CLI-Befehle und nicht einfach SQL sind
Zwei Gründe:
1. **Atomarität** — die Erstellung einer Seite erfordert Einfügungen in zwei Tabellen (die Seite und ihre Textzeilen). Die CLI verpackt dies in eine Transaktion.
2. **Eindeutigkeit des Slugs** — die CLI überprüft vor der Einfügung auf Kollisionen und gibt einen freundlicheren Fehler als eine Verletzung einer SQL-Einschränkung.
## Ohne das CMS-Modul
`-new-cms-page` und `-delete-cms-page` beenden mit einem nicht-nulligen Exit-Code und einer klaren Fehlermeldung, wenn das Modul `cms` nicht installiert ist. Fügen Sie es mit folgendem Befehl hinzu:
```bash
./nibiru -m cms
./nibiru -mi local
```
(Siehe [Module](/core/modules/) für die Funktionalität von `./nibiru -m` und die Migrationsdateien, die das `cms`-Modul mitbringt.)
## Inhalte nach der Erstellung bearbeiten
Die Befehlszeilenschnittstelle (CLI) bearbeitet keinen Text dies ist absichtlich der Web-Benutzeroberfläche des CMS-Moduls überlassen worden. Aus dem Produktionscode:
```php
// Read all text identifiers for a controller path + language
$texts = \Nibiru\Module\Cms\Cms::init('about-us')
->loadCmsTemplateTextsByControllerPath('about-us/page', $this->language);
foreach ($texts as $t) {
\Nibiru\View::assign([
$t['cms_template_texts_text_identifier']
=> $t['cms_template_texts_text_content']
]);
}
```
Das Ergebnis: Jede `{$identifier}` im Template wird automatisch mit dem Inhalt der aktuellen Sprache aufgefüllt. Nicht-Entwickler verwalten den Text über die Admin-Oberfläche; Entwickler verwalten das Layout über das Template.

View File

@@ -0,0 +1,104 @@
---
title: "Migrationen"
description: "Nummerierte SQL-Migrationen, die durch den Nibiru CLI getrieben werden."
---
Migrationen sind flache SQL-Dateien im Verzeichnis `application/settings/config/database/`, die in numerischer Reihenfolge ausgeführt werden. Die Befehlszeilenschnittstelle verfolgt, welche Dateien ausgeführt wurden, und überspringt sie bei nachfolgenden Aufrufen.
## Dateinamenskonventionen
```
NNN-<slug>.sql
```
- `NNN`: Null aufgefüllte dreistellige Nummer für die Sortierreihenfolge.
- `<slug>`: Bindestrich-getrennte Beschreibung (`add-account-email`, `create-acl-data`).
Beispiel-Aufschlüsselung:
```
001-acl.sql
002-account.sql
003-api_registry.sql
004-timeanddate.sql
005-user.sql
006-user_to_account.sql
007-timeanddate_to_account.sql
008-user_to_acl.sql
009-account_to_api_registry.sql
010-timeanddate_to_user.sql
011-acl-data.sql
012-add-unique-key-user.sql
013-add-account-email.sql
```
## Migrations ausführen
```bash
./nibiru -mi local # APPLICATION_ENV=development
APPLICATION_ENV=staging ./nibiru -mi staging
APPLICATION_ENV=production ./nibiru -mi production
```
Der `{env}`-Argument und die `APPLICATION_ENV` sollten übereinstimmen. Migrationen zielen auf die in `settings.<env>.ini` unter `[DATABASE]` konfigurierte Datenbank ab.
## Was der Runner macht
Für jede `*.sql`-Datei in numerischer Reihenfolge:
1. Sucht nach seinem Dateinamen in der Tabelle `_migrations`.
2. Wenn nicht vorhanden, öffnet eine Transaktion (wenn der Treiber DDL-Transaktionen unterstützt), führt die Datei aus und fügt einen Datensatz bei Erfolg ein.
3. Wenn eine Anweisung fehlschlägt, wird rückgängig gemacht und mit einem Nicht-null-Ausgang beendet.
Die `_migrations` Tabelle wird beim ersten Start automatisch erstellt.
## Idempotente SQL
Schreiben Sie immer Migrationen, die ohne Fehler erneut ausgeführt werden können, nach einem teilweisen Fehler:
```sql
CREATE TABLE IF NOT EXISTS api_registry (
api_registry_id INT(11) NOT NULL AUTO_INCREMENT,
api_registry_name VARCHAR(255) NOT NULL,
PRIMARY KEY (api_registry_id),
UNIQUE KEY api_registry_name_uk (api_registry_name)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
```
Für PostgreSQL:
```sql
CREATE TABLE IF NOT EXISTS api_registry (
api_registry_id SERIAL PRIMARY KEY,
api_registry_name TEXT NOT NULL UNIQUE
);
```
Für ALTER-Anweisungen bevorzugen Sie Klauseln mit `IF NOT EXISTS` auf unterstützten Engines:
```sql
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS user_email VARCHAR(255);
```
Falls Ihr Motor keine Unterstützung für `IF NOT EXISTS` bei der Änderung bietet, umschließen Sie die Anweisung in eine Guard-Abfrage, die nichts tut, wenn die Änderung bereits vorhanden ist.
## Befehle zum Zurücksetzen
::: caution
Die Reset-Befehle leeren die Migrations-Audit-Tabelle sie löschen Ihre Datenbanktabellen nicht. Nach einem Reset führt der nächste `-mi` *jede* Migration erneut aus. Stellen Sie sicher, dass Ihr SQL idempotent ist, bevor Sie zurücksetzen.
:::
```bash
./nibiru -mi-reset local # forget all applied migrations
./nibiru -mi-reset-file 005-user.sql local # forget a single file
```
Das Einzeldateiformular ist nützlich, wenn Sie einen Fehler in einer zuvor angewandten Migration behoben haben und nur diese Datei erneut ausführen möchten.
## Verzweigungsreinheit
Zwei Ingenieure, die an parallelen Zweigen arbeiten, können beide `015-…` hinzufügen und kollidieren. Konventionen, die helfen:
- **Zahlen in Pull-Request-Titeln vorbehalten**, bevor Sie die SQL schreiben.
- **Verwenden Sie einen großen Abstand** für Hotfixes (z.B. behalten Sie `099`, `199`, `299` für Notfall-Picks vor).
- **Fügen Sie lieber additive Migrationen** (neue Tabellen, neue Spalten) als zerstörende ones (Drops) hinzu. Diese führen sauberer zusammen.
## Zusammenführen
Für langfristige Projekte sollten alte Migrationen regelmäßig in eine einzelne Seed-Datei zusammengefasst werden, die das *aktuelle* Schema darstellt. Verschieben Sie die Originalien nach `archive/`, sodass der Überwachungsverlauf weiterhin besteht, und erstellen Sie eine neue `000-baseline-2026.sql`, die alles auf einmal erstellt. Aktualisieren Sie den Migrationsrunner mit einer manuellen `INSERT INTO _migrations`, um alte Dateien als angewendet zu markieren.
## Schema-first Modelle
Wenn `[GENERATOR] database = true` ist, werden die Modelle nach jeder Migration aus dem Live-Schema neu generiert. Also ist ein typischer Deploy-Workflow:
```bash
./nibiru -mi production
# Generator regenerates models on the next request.
./nibiru -cache-clear
```
Für zero-downtime Deploys: Deaktivieren Sie den Generator (`database = false`) und übertragen Sie die neu generierten Modelle zusammen mit den Migrationen, auf denen sie abhängen.

View File

@@ -0,0 +1,80 @@
---
title: "Die Nibiru CLI"
description: "Jedes Flag, jeder Unterbefehl des `./nibiru` Binaries."
---
Die `./nibiru`-Binärdatei ist ein kompiliertes Befehlszeilentool, das in jedem Nibiru-Projekt enthalten ist. Es erstellt Module, Controller und Plugins, führt Migrationen durch, verwaltet den Cache und (mit dem CMS-Modul) erstellt und löscht Seiten.
```
_ _ _ _ _ ______ _
| \ | (_) | (_) | ____| | |
| \| |_| |__ _ _ __ _ _ | |__ _ __ __ _ _ __ ___ _____ _____ _ _| | __
| . ` | | '_ \| | '__| | | | | __| '__/ _` | '_ ` _ \ / _ \ \ /\ / / _ \| '__| |/ /
| |\ | | |_) | | | | |_| | | | | | | (_| | | | | | | __/\ V V / (_) | | | <
|_| \_|_|_.__/|_|_| \__,_| |_| |_| \__,_|_| |_| |_|\___| \_/\_/ \___/|_| |_|\_\
```
## Alle Flags
| Flag | Was es macht |
|---|---|
| `-m {name}` | Erstellt ein neues Modul mit dem Namen `{name}`. Fügen Sie `-g` hinzu, um Graylog-Protokollierungshooks zu verbinden. |
| `-c {name}` | Erstellt einen neuen Controller `{name}` zusammen mit seiner Vorlage. |
| `-p {name} -m {module}` | Erstellt ein neues Plugin `{name}` innerhalb von `{module}`. Fügen Sie `-g` für Graylog hinzu. |
| `-cache-clear` | Löscht `application/view/templates_c/` und `application/view/cache/`. |
| `-s` | Bootstrap-Framework-Ordner erstellen und Berechtigungen reparieren. Führen Sie einmal nach der Installation aus. |
| `-mi {env}` | Führt Migrationen aus `application/settings/config/database/` für `local`, `staging` oder `production` durch. |
| `-mi-reset {env}` | Löscht die Migrations-Audit-Tabelle für `{env}`. **Zerstörend.** |
| `-mi-reset-file {file} {env}` | Vergisst, dass eine einzelne Migrationsdatei für `{env}` ausgeführt wurde. |
| `-ws {URL} -wp {PORT}` | Verbindet sich mit einem WebSocket an `{URL}:{PORT}` (interaktive REPL). |
| `-new-cms-page {name}` | (Nur CMS-Modul) Erstellt eine neue CMS-Seite, die einer vorhandenen Vorlage gebunden ist. |
| `-delete-cms-page {name}` | (Nur CMS-Modul) Löscht eine CMS-Seite. |
| `-h` | Zeigt den Hilfetext an. |
| `-v` / `-version` | Gibt die Version des Binaries und der Framework-Version aus. |
## Tägliche Befehle, die Sie tatsächlich verwenden werden
```bash
# create a controller + view
./nibiru -c products
# create a module with Graylog hooks
./nibiru -m billing -g
# create a plugin inside that module
./nibiru -p invoices -m billing
# run migrations
./nibiru -mi local
# clear the Smarty cache after a deploy
./nibiru -cache-clear
# show framework version
./nibiru -v
```
## Umgebungen
Die meisten Befehle beachten `APPLICATION_ENV`:
```bash
APPLICATION_ENV=production ./nibiru -mi production
APPLICATION_ENV=staging ./nibiru -mi staging
```
Der nachfolgende `{env}`-Argument für `-mi` wählt das Ziel der Migrationen aus; beide müssen übereinstimmen.
## Auf was die Befehlszeilenschnittstelle (CLI) aufbaut
Die ausführbare Datei ist eine kompilierte C++-Anwendung, die gegen die MySQL-, PostgreSQL (libpq)- und ODBC-Clients-Bibliotheken verlinkt. Die konditionale Kompilierung bedeutet, dass eine mit libpq gebaute Binärdatei auch für MySQL-einzige Bereitstellungen funktioniert ein sanfter Abstieg anstatt einer festen Abhängigkeit.
Sie finden die ausführbare Datei im Projektstamm neben `index.php`. Sie ist direkt ausführbar (`chmod +x nibiru`, falls erforderlich).
## CI-Integration
Ein einfacher Schritt in GitHub Actions:
```yaml
- name: Run migrations
run: |
APPLICATION_ENV=production ./nibiru -mi production
env:
DB_HOST: ${{ secrets.DB_HOST }}
DB_USER: ${{ secrets.DB_USER }}
DB_PASS: ${{ secrets.DB_PASS }}
```
Die CLI beendet sich mit einem Nicht-null-Ausgabestatus, wenn eine Migration fehlschlägt, sodass CI SQL-Fehler erfasst.

View File

@@ -0,0 +1,111 @@
---
title: "Modul- und Controllergerüste"
description: "Generieren Sie Controller, Module und Plugins mit der Befehlszeilenschnittstelle (CLI)."
---
## Controller
```bash
./nibiru -c products
```
Erstellt zwei Dateien:
```
application/controller/productsController.php
application/view/templates/products.tpl
```
Der Controller-Stub:
```php
<?php
namespace Nibiru;
use Nibiru\Adapter\Controller;
class productsController extends Controller
{
public function pageAction() {
View::assign(['title' => 'Products']);
}
public function navigationAction() {
JsonNavigation::getInstance()->loadJsonNavigationArray();
}
}
```
Die Vorlagestub:
```smarty
{include 'shared/header.tpl'}
<body>
{include file="navigation.tpl"}
<main class="container">
<h1>{$title}</h1>
</main>
{include 'shared/footer.tpl'}
</body>
```
Controller sind nur PHP-Dateien keine JS- oder CSS-Skelettstruktur, sodass Sie die Verwaltung der Assets selbstständig steuern können.
## Module
```bash
./nibiru -m billing
```
Erstellt:
```
application/module/billing/
├── billing.php
├── interfaces/billing.php
├── plugins/
├── settings/billing.ini
└── traits/
```
Die Hauptklasse implementiert `IModule` und macht einen Konstruktor verfügbar, der die Registrierungskonfiguration des Moduls lädt. Fügen Sie den Flag `-g` hinzu, um die Graylog-Observer-Wiring standardmäßig einzubinden:
```bash
./nibiru -m billing -g
```
Wenn `-g` gesetzt ist, importiert das Scafolding einen `Graylog`-Observer, fügt ihn im Konstruktor hinzu und gibt bei wichtigen Zustandsänderungen `notify()` aus sodass jeder GELF-fähige Graylog-Server die Modulereignisse ohne zusätzliche Anpassungen aufnimmt.
## Plugins
Ein Plugin lebt innerhalb eines Moduls:
```bash
./nibiru -p invoices -m billing
```
Erstellt `application/module/billing/plugins/invoices.php`:
```php
<?php
namespace Nibiru\Module\Billing\Plugin;
use Nibiru\Module\Billing\Billing;
class Invoices extends Billing
{
public function listOpen(): array
{
return \Nibiru\Pdo::fetchAll(
'SELECT * FROM invoices WHERE status = :s ORDER BY due_date',
[':s' => 'open']
);
}
}
```
Plugins erben vom Modul und teilen daher dessen Registry, Einstellungen und Observer-Maschinerie.
## Bootstrap (`-s`)
`./nibiru -s` wird nach der Installation (oder nach dem Abrufen eines frischen Checkout) ausgeführt, um:
- Erstellen Sie `application/view/templates_c/` und `application/view/cache/`, falls diese fehlen.
- Überprüfen und korrigieren Sie die Berechtigungen (schreibbar für den Webserver-Benutzer) dieser Ordner.
- Stellen Sie sicher, dass die erforderlichen PHP-Erweiterungen geladen sind.
- Überprüfen Sie, ob der Datenbanktreiber in Ihrer INI-Datei von diesem Binärbuild unterstützt wird.
Es ist sicher, mehrmals zu führen.
## Cache leeren (`-cache-clear`)
Löscht sowohl den Smarty-Kompilierungs-Cache als auch den gerenderten HTML-Cache:
```bash
./nibiru -cache-clear
```
Führen Sie nach einer Bereitstellung aus, wenn:
- Sie `.tpl`-Dateien geändert haben,
- Sie die `[ENGINE]`-Caching-Einstellungen geändert haben,
- Sie Smarty-Plugins modifiziert haben.
Der Cache wird beim nächsten Anfrage erneut generiert.

Some files were not shown because too many files have changed in this diff Show More