feat: style
@@ -3,6 +3,7 @@ import AppShell from "./components/shared/AppShell";
|
||||
import ComposeView from "./routes/ComposeView";
|
||||
import BrowseView from "./routes/BrowseView";
|
||||
import EditorView from "./routes/EditorView";
|
||||
import SettingsView from "./routes/SettingsView";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -10,6 +11,7 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<ComposeView />} />
|
||||
<Route path="/browse" element={<BrowseView />} />
|
||||
<Route path="/settings" element={<SettingsView />} />
|
||||
<Route path="/editor/new" element={<EditorView />} />
|
||||
<Route path="/editor/:name" element={<EditorView />} />
|
||||
</Routes>
|
||||
|
||||
|
Before Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 496 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 552 KiB |
@@ -4,6 +4,7 @@ import pointerSvg from "@/assets/pointer.min.svg";
|
||||
/**
|
||||
* Floating pointer that tracks the CodeMirror cursor with smooth lerp animation.
|
||||
* Positions itself right next to the left border of the editor container.
|
||||
* Uses a ResizeObserver to keep horizontal position synced on window resize.
|
||||
*/
|
||||
export default function EditorPointer() {
|
||||
const [y, setY] = useState<number | null>(null);
|
||||
@@ -12,19 +13,58 @@ export default function EditorPointer() {
|
||||
const currentRef = useRef(0);
|
||||
const rafRef = useRef(0);
|
||||
const activeRef = useRef(false);
|
||||
const [clickKey, setClickKey] = useState(0);
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const cmRef = useRef<Element | null>(null);
|
||||
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 });
|
||||
const eyeTargetRef = useRef({ x: 0, y: 0 });
|
||||
const eyeCurrentRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const ease = 0.09;
|
||||
const eyeEase = 0.06;
|
||||
|
||||
const updateLeft = () => {
|
||||
if (cmRef.current) {
|
||||
setEditorLeft(cmRef.current.getBoundingClientRect().left);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const pointerX = (cmRef.current?.getBoundingClientRect().left ?? 0) - 100;
|
||||
const pointerY = currentRef.current;
|
||||
const dx = e.clientX - pointerX;
|
||||
const dy = e.clientY - pointerY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||||
const maxShift = 1.5;
|
||||
eyeTargetRef.current = {
|
||||
x: (dx / dist) * maxShift,
|
||||
y: (dy / dist) * maxShift,
|
||||
};
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
|
||||
const onEditorClick = () => {
|
||||
clearTimeout(clickTimerRef.current);
|
||||
setClickKey((k) => k + 1);
|
||||
clickTimerRef.current = setTimeout(() => setClickKey(0), 200);
|
||||
};
|
||||
|
||||
const onCursorMove = (e: Event) => {
|
||||
const { top } = (e as CustomEvent).detail;
|
||||
targetRef.current = top;
|
||||
|
||||
// Find editor container left edge once (or when it changes)
|
||||
const cm = document.querySelector(".cm-editor");
|
||||
if (cm) {
|
||||
setEditorLeft(cm.getBoundingClientRect().left);
|
||||
// Find editor container and attach click listener lazily
|
||||
if (!cmRef.current) {
|
||||
const cm = document.querySelector(".cm-editor");
|
||||
if (cm) {
|
||||
cmRef.current = cm;
|
||||
cm.addEventListener("mousedown", onEditorClick);
|
||||
ro.observe(cm);
|
||||
}
|
||||
}
|
||||
updateLeft();
|
||||
|
||||
if (!activeRef.current) {
|
||||
currentRef.current = top;
|
||||
@@ -43,15 +83,34 @@ export default function EditorPointer() {
|
||||
}
|
||||
setY(currentRef.current);
|
||||
}
|
||||
// Lerp eyes toward target
|
||||
const ec = eyeCurrentRef.current;
|
||||
const et = eyeTargetRef.current;
|
||||
ec.x += (et.x - ec.x) * eyeEase;
|
||||
ec.y += (et.y - ec.y) * eyeEase;
|
||||
setEyeOffset({ x: ec.x, y: ec.y });
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
window.addEventListener("cm-cursor-move", onCursorMove);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
|
||||
// Keep left position updated on resize
|
||||
const ro = new ResizeObserver(() => updateLeft());
|
||||
const existingCm = document.querySelector(".cm-editor");
|
||||
if (existingCm) {
|
||||
cmRef.current = existingCm;
|
||||
ro.observe(existingCm);
|
||||
}
|
||||
window.addEventListener("resize", updateLeft);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("cm-cursor-move", onCursorMove);
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("resize", updateLeft);
|
||||
cmRef.current?.removeEventListener("mousedown", onEditorClick);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -59,7 +118,8 @@ export default function EditorPointer() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="editor-pointer"
|
||||
className={`editor-pointer${clickKey ? " editor-pointer-click" : ""}`}
|
||||
key={clickKey}
|
||||
style={{ top: y, left: editorLeft }}
|
||||
>
|
||||
<div
|
||||
@@ -69,6 +129,18 @@ export default function EditorPointer() {
|
||||
maskImage: `url(${pointerSvg})`,
|
||||
}}
|
||||
/>
|
||||
<div className="editor-pointer-eye" style={{ top: 33, left: 134 }}>
|
||||
<div
|
||||
className="editor-pointer-iris"
|
||||
style={{ transform: `translate(${eyeOffset.x}px, ${eyeOffset.y}px)` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-pointer-eye" style={{ top: 29, left: 144 }}>
|
||||
<div
|
||||
className="editor-pointer-iris"
|
||||
style={{ transform: `translate(${eyeOffset.x}px, ${eyeOffset.y}px)` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import frameTerraSvg from "@/assets/frame.themed.svg";
|
||||
import frameAzureSvg from "@/assets/frame.azure.svg";
|
||||
import frameSvg from "@/assets/frame.themed.svg";
|
||||
import NavMenu from "./NavMenu";
|
||||
|
||||
const TITLE = `
|
||||
@@ -99,8 +98,6 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
||||
localStorage.setItem("micronomicon-theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const frameSvg = theme === "azure" ? frameAzureSvg : frameTerraSvg;
|
||||
|
||||
const toggleTheme = () => setTheme(t => t === "terra" ? "azure" : "terra");
|
||||
|
||||
return (
|
||||
@@ -109,13 +106,21 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
||||
<main className="flex-1 overflow-auto" data-scroll-root>
|
||||
<div className="relative max-w-5xl min-w-5xl mx-auto min-h-full">
|
||||
{/* Frame SVG — background */}
|
||||
<img
|
||||
key={theme}
|
||||
src={frameSvg}
|
||||
alt=""
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 w-full min-w-full pointer-events-none select-none"
|
||||
style={{ zIndex: 0, objectFit: "fill" }}
|
||||
style={{
|
||||
zIndex: 0,
|
||||
height: "1315px",
|
||||
background: "var(--primary)",
|
||||
WebkitMaskImage: `url(${frameSvg})`,
|
||||
maskImage: `url(${frameSvg})`,
|
||||
WebkitMaskSize: "cover",
|
||||
maskSize: "cover",
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
maskRepeat: "no-repeat",
|
||||
objectFit: "fill"
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Top hole — ASCII title */}
|
||||
@@ -147,7 +152,7 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
||||
className="absolute z-20"
|
||||
style={{ top: 200, left: -210 }}
|
||||
>
|
||||
<NavMenu />
|
||||
<NavMenu theme={theme} onToggleTheme={toggleTheme} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -155,14 +160,6 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
||||
</main>
|
||||
<footer className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground py-4 shrink-0 tracking-widest uppercase">
|
||||
<span>Becoming with hubris · MMXXVI</span>
|
||||
<span className="text-muted-foreground/50">│</span>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="hover:text-foreground transition-colors cursor-pointer"
|
||||
title={`Switch to ${theme === "terra" ? "Azure" : "Terracotta"} theme`}
|
||||
>
|
||||
{theme === "terra" ? "◐ AZURE" : "◑ TERRA"}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
<Toaster />
|
||||
|
||||
@@ -5,6 +5,7 @@ import menuSvg from "@/assets/menu.min.svg";
|
||||
const NAV_ITEMS = [
|
||||
{ label: "Browse", path: "/browse" },
|
||||
{ label: "Compose", path: "/" },
|
||||
{ label: "Settings", path: "/settings" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -47,7 +48,12 @@ function useSmoothScroll(scrollSelector: string, ease = 0.08) {
|
||||
return offset;
|
||||
}
|
||||
|
||||
export default function NavMenu() {
|
||||
interface NavMenuProps {
|
||||
theme: "terra" | "azure";
|
||||
onToggleTheme: () => void;
|
||||
}
|
||||
|
||||
export default function NavMenu({ theme, onToggleTheme }: NavMenuProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const scrollY = useSmoothScroll("[data-scroll-root]", 0.07);
|
||||
@@ -82,6 +88,15 @@ export default function NavMenu() {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Theme toggle — bottom container */}
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
className="nav-menu-theme-toggle"
|
||||
title={`Switch to ${theme === "terra" ? "Azure" : "Terracotta"} theme`}
|
||||
>
|
||||
{theme === "terra" ? "◐ AZURE" : "◑ TERRA"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -351,8 +351,9 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding-top: 168px;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@@ -380,6 +381,27 @@
|
||||
background: var(--primary) / 0.08;
|
||||
}
|
||||
|
||||
.nav-menu-theme-toggle {
|
||||
position: absolute;
|
||||
bottom: 97px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-menu-theme-toggle:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* ── Editor Pointer ── */
|
||||
|
||||
.editor-pointer {
|
||||
@@ -391,6 +413,24 @@
|
||||
margin-top: -20px;
|
||||
}
|
||||
|
||||
.editor-pointer-click {
|
||||
animation: pointer-click 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes pointer-click {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateX(-96%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-pointer-img {
|
||||
width: 200px;
|
||||
height: 130px;
|
||||
@@ -403,4 +443,26 @@
|
||||
-webkit-mask-position: center;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.editor-pointer-eye {
|
||||
position: absolute;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: black;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor-pointer-iris {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-top: -1.5px;
|
||||
margin-left: -1.5px;
|
||||
transition: transform 0.15s ease-out;
|
||||
}
|
||||
}
|
||||
7
frontend/src/routes/SettingsView.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function SettingsView() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Settings
|
||||
</div>
|
||||
);
|
||||
}
|
||||