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:
936
docs/design-system/source/design-canvas.jsx
Normal file
936
docs/design-system/source/design-canvas.jsx
Normal 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 });
|
||||
|
||||
523
docs/design-system/source/docs-system.html
Normal file
523
docs/design-system/source/docs-system.html
Normal 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
BIN
docs/design-system/source/docs-system/assets/lotus.png
Normal file
BIN
docs/design-system/source/docs-system/assets/lotus.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
BIN
docs/design-system/source/docs-system/assets/nibiru-full.png
Normal file
BIN
docs/design-system/source/docs-system/assets/nibiru-full.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
1103
docs/design-system/source/docs-system/components.css
Normal file
1103
docs/design-system/source/docs-system/components.css
Normal file
File diff suppressed because it is too large
Load Diff
291
docs/design-system/source/docs-system/components/extras.jsx
Normal file
291
docs/design-system/source/docs-system/components/extras.jsx
Normal 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 });
|
||||
192
docs/design-system/source/docs-system/components/navigation.jsx
Normal file
192
docs/design-system/source/docs-system/components/navigation.jsx
Normal 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 });
|
||||
@@ -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 });
|
||||
197
docs/design-system/source/docs-system/tokens.css
Normal file
197
docs/design-system/source/docs-system/tokens.css
Normal 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%);
|
||||
}
|
||||
1240
docs/design-system/source/index-v2.html
Normal file
1240
docs/design-system/source/index-v2.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user