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

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