Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ user-facing notes live in [`docs/updates/`](docs/updates/) (English and

### Added

- Editor: light mode. The editor follows the system theme and a button in the
header cycles system, light and dark (remembered); the 3D viewport, edges,
grid, axes and the code editor switch with it, in place, without reloading
the model or losing the camera and selection.
- `sca edit [WORKSPACE]`: the web editor, now part of the SDK
(`simplecadapi.editor`, UI sources in `editor-web/`; needs the `editor`
extra). It runs a workspace's notebooks in a marimo kernel with the SDK's
Expand Down
15 changes: 15 additions & 0 deletions editor-web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SimpleCAD Studio</title>
<script>
// Pick the theme before first paint (no flash): the saved choice, else the system.
// Keep in step with src/lib/theme.ts.
(function () {
var choice = "system";
try {
choice = localStorage.getItem("sca-theme") || "system";
} catch (e) {}
var dark =
choice === "dark" ||
(choice !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = dark ? "dark" : "light";
})();
</script>
</head>
<body>
<div id="root"></div>
Expand Down
9 changes: 7 additions & 2 deletions editor-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { fetchPreview, getWorkspace, openNotebook, postViewport, type ModuleRow,
import { parsePreview } from "@/lib/preview";
import { EnvelopeSocket, type Envelope } from "@/lib/envelope";
import { useRunLog } from "@/lib/runlog";
import { useTheme } from "@/lib/theme";
import { selectionPayload, variableNames, type SelectionItem } from "@/lib/selection";
import { cn } from "@/lib/utils";
import { Viewport, type PickResult, type SelectionMode } from "@/scene/Viewport";
Expand Down Expand Up @@ -63,6 +64,7 @@ export function App() {
const [loading, setLoading] = useState<string | null>("connecting");

const kind = notebooks.find((row) => row.path === currentPath)?.kind ?? null;
const theme = useTheme();
const log = useRunLog();
const running = cells.some((cell) => cell.status === "running" || cell.status === "queued");
const handleStop = useCallback(() => {
Expand Down Expand Up @@ -318,6 +320,8 @@ export function App() {
kernelAlive={kernelAlive}
status={status}
marimoUrl={marimoUrl}
themePreference={theme.preference}
onTheme={theme.cycle}
running={running}
onStop={handleStop}
onRunAll={() => {
Expand Down Expand Up @@ -376,7 +380,7 @@ export function App() {
className={cn(
"border-2 px-2 py-px text-[10px] font-semibold uppercase tracking-[0.1em] transition-colors",
selectionMode === mode
? "border-white bg-white text-black"
? "border-primary bg-primary text-primary-foreground"
: "border-transparent text-muted-foreground hover:border-input hover:text-foreground",
)}
onClick={() => setSelectionMode(mode)}
Expand All @@ -400,6 +404,7 @@ export function App() {
selectionMode={selectionMode}
selection={selection}
sceneVersion={sceneVersion}
theme={theme.resolved}
onPick={handlePick}
onClear={handleClear}
/>
Expand All @@ -411,7 +416,7 @@ export function App() {
<Inspector selection={selection} />
</ResizablePanel>
</ResizablePanelGroup>
<LoadingMask active={loading !== null} message={loading ?? ""} />
<LoadingMask active={loading !== null} message={loading ?? ""} theme={theme.resolved} />
</div>
<Dock
entries={log.entries}
Expand Down
6 changes: 3 additions & 3 deletions editor-web/src/components/CellEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ function CellGap({ after, onInsert }: { after: string | null; onInsert: (after:
<button
type="button"
title="Insert a cell here"
className="z-10 flex h-4 w-4 items-center justify-center border-2 border-input bg-black text-muted-foreground opacity-0 transition-opacity hover:border-focus hover:text-focus group-hover/gap:opacity-100"
className="z-10 flex h-4 w-4 items-center justify-center border-2 border-input bg-background text-muted-foreground opacity-0 transition-opacity hover:border-focus hover:text-focus group-hover/gap:opacity-100"
onClick={(event) => {
event.stopPropagation();
onInsert(after);
Expand Down Expand Up @@ -337,7 +337,7 @@ function CellBlock({
return (
<section
className={cn(
"border-2 bg-black transition-colors",
"border-2 bg-background transition-colors",
isSelection ? "border-focus/60" : failed ? "border-fail/70" : "border-border",
stale && !failed && "border-dashed",
armed && "border-fail",
Expand Down Expand Up @@ -397,7 +397,7 @@ function CellBlock({
title={armed ? "Click again to delete this cell" : "Delete this cell"}
onClick={onDelete}
data-delete-armed={cell.id}
className={armed ? "border-fail bg-fail text-black hover:border-fail hover:text-black" : "hover:text-fail"}
className={armed ? "border-fail bg-fail text-primary-foreground hover:border-fail hover:text-primary-foreground" : "hover:text-fail"}
>
<Trash2 className="h-3.5 w-3.5" />
</IconButton>
Expand Down
2 changes: 1 addition & 1 deletion editor-web/src/components/Dock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function Dock({
const percent = batch && batch.total > 0 ? Math.round((batch.done / batch.total) * 100) : 0;

return (
<section className="shrink-0 border-t-2 border-border bg-black" aria-label="log">
<section className="shrink-0 border-t-2 border-border bg-background" aria-label="log">
{open && (
<div ref={list} className="h-44 overflow-y-auto border-b-2 border-border px-3 py-1.5 text-[11px] leading-5">
{entries.length === 0 ? (
Expand Down
22 changes: 20 additions & 2 deletions editor-web/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { ExternalLink, Play, Square } from "lucide-react";
import { ExternalLink, Monitor, Moon, Play, Square, Sun } from "lucide-react";

import type { ThemePreference } from "@/lib/theme";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
Expand All @@ -14,6 +16,8 @@ export function Header({
running,
onStop,
marimoUrl,
themePreference,
onTheme,
}: {
notebook: string | null;
kind: string | null;
Expand All @@ -24,10 +28,12 @@ export function Header({
running: boolean;
onStop: () => void;
marimoUrl: string | null;
themePreference: ThemePreference;
onTheme: () => void;
}) {
const failing = status.startsWith("error");
return (
<header className="flex h-11 shrink-0 items-center gap-3 border-b-2 border-border bg-black px-3">
<header className="flex h-11 shrink-0 items-center gap-3 border-b-2 border-border bg-background px-3">
<span className="text-[13px] font-bold uppercase tracking-[0.14em]">SimpleCAD</span>
<span className="h-5 w-[2px] bg-border" />
{notebook ? (
Expand Down Expand Up @@ -64,6 +70,18 @@ export function Header({
</a>
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={onTheme}
title={`Theme: ${themePreference} (click: ${NEXT_THEME[themePreference]})`}
aria-label={`Theme: ${themePreference}. Switch to ${NEXT_THEME[themePreference]}`}
>
{themePreference === "system" ? <Monitor /> : themePreference === "light" ? <Sun /> : <Moon />}
{themePreference}
</Button>
</header>
);
}

const NEXT_THEME: Record<ThemePreference, ThemePreference> = { system: "light", light: "dark", dark: "system" };
36 changes: 25 additions & 11 deletions editor-web/src/components/LoadingMask.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const WAVE_SPEED = 0.0009;
* dot-matrix with a ripple that spreads from the middle, and a small retro
* window saying what is going on and for how long. It appears only after
* `DELAY_MS`, so quick operations do not flash it. */
export function LoadingMask({ active, message }: { active: boolean; message: string }) {
export function LoadingMask({ active, message, theme }: { active: boolean; message: string; theme: string }) {
const DELAY_MS = 250;
const [visible, setVisible] = useState(false);
const [elapsed, setElapsed] = useState(0);
Expand All @@ -45,11 +45,11 @@ export function LoadingMask({ active, message }: { active: boolean; message: str
<div
role="status"
aria-live="polite"
className="absolute inset-0 z-50 flex cursor-progress items-center justify-center bg-[#1b1b1b]/75"
className="absolute inset-0 z-50 flex cursor-progress items-center justify-center bg-overlay"
>
<Ripple />
<div className="relative w-[280px] border-2 border-white/80 bg-black shadow-[6px_6px_0_0_rgba(255,255,255,0.1)]">
<div className="flex h-6 items-center justify-between bg-white/85 px-2 text-[11px] font-bold uppercase tracking-[0.18em] text-black">
<Ripple theme={theme} />
<div className="relative w-[280px] border-2 border-foreground/80 bg-background shadow-[6px_6px_0_0_var(--shadow-hard)]">
<div className="flex h-6 items-center justify-between bg-foreground/85 px-2 text-[11px] font-bold uppercase tracking-[0.18em] text-background">
<span>loading</span>
<span className="tabular-nums">
{minutes}:{seconds}
Expand All @@ -58,7 +58,7 @@ export function LoadingMask({ active, message }: { active: boolean; message: str
<div className="flex items-center gap-4 px-4 py-4">
<Spinner />
<div className="min-w-0 flex-1">
<p className="truncate text-[12px] uppercase tracking-[0.1em] text-white" title={message}>
<p className="truncate text-[12px] uppercase tracking-[0.1em] text-foreground" title={message}>
{message}
</p>
<p className="mt-1 text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
Expand All @@ -81,7 +81,7 @@ function Spinner() {
return (
<span
key={cell}
className={cn("block bg-white", order < 0 && "invisible", order >= 0 && "sca-chase")}
className={cn("block bg-foreground", order < 0 && "invisible", order >= 0 && "sca-chase")}
style={order >= 0 ? { animationDelay: `${order * 0.1}s` } : undefined}
/>
);
Expand All @@ -93,18 +93,32 @@ function Spinner() {
/** A strip of blocks filling and emptying: the load has no known length. */
function Progress() {
return (
<div className="flex gap-[3px] border-t-2 border-white px-2 py-1.5" aria-hidden>
<div className="flex gap-[3px] border-t-2 border-foreground px-2 py-1.5" aria-hidden>
{Array.from({ length: 20 }, (_, index) => (
<span key={index} className="sca-bar block h-[6px] flex-1 bg-white" style={{ animationDelay: `${index * 0.07}s` }} />
<span key={index} className="sca-bar block h-[6px] flex-1 bg-foreground" style={{ animationDelay: `${index * 0.07}s` }} />
))}
</div>
);
}

/** A CSS color variable of the page (a #rrggbb token) as "r,g,b", for drawing on a canvas. */
function cssRgb(name: string, fallback: string): string {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const match = /^#([0-9a-f]{6})$/i.exec(value);
if (!match) return fallback;
const n = parseInt(match[1], 16);
return `${(n >> 16) & 255},${(n >> 8) & 255},${n & 255}`;
}

/** The dot-matrix: square dots whose brightness follows a ring-shaped wave
* travelling out from the middle, quantised to four levels. */
function Ripple() {
function Ripple({ theme }: { theme: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
// The dots take the theme's ink: the crest is the focus color, the rest the foreground.
const inks = useRef({ crest: "255,154,46", base: "255,255,255" });
useEffect(() => {
inks.current = { crest: cssRgb("--focus", "255,154,46"), base: cssRgb("--foreground", "255,255,255") };
}, [theme]);

useEffect(() => {
const canvas = canvasRef.current;
Expand Down Expand Up @@ -142,7 +156,7 @@ function Ripple() {
}
}
paths.forEach((path, level) => {
context.fillStyle = level === LEVELS.length - 1 ? `rgba(255,154,46,${LEVELS[level]})` : `rgba(255,255,255,${LEVELS[level]})`;
context.fillStyle = `rgba(${level === LEVELS.length - 1 ? inks.current.crest : inks.current.base},${LEVELS[level]})`;
context.fill(path);
});
};
Expand Down
2 changes: 1 addition & 1 deletion editor-web/src/components/ParamControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export function ParamControl({
onClick={() => forwardParam(param.id, !on)}
>
<span className={ink}>{label}</span>
<span className={cn("border-2 px-1.5 text-[10px] font-bold uppercase", on ? "border-white bg-white text-black" : "border-input text-muted-foreground")}>
<span className={cn("border-2 px-1.5 text-[10px] font-bold uppercase", on ? "border-primary bg-primary text-primary-foreground" : "border-input text-muted-foreground")}>
{on ? "on" : "off"}
</span>
</button>
Expand Down
4 changes: 2 additions & 2 deletions editor-web/src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ const badgeVariants = cva(
{
variants: {
variant: {
default: "border-white bg-white text-black",
default: "border-primary bg-primary text-primary-foreground",
secondary: "border-input text-muted-foreground",
outline: "border-input text-foreground",
focus: "border-focus text-focus",
pass: "border-pass text-pass",
fail: "border-fail text-fail",
name: "border-name text-name",
destructive: "border-fail bg-fail text-black",
destructive: "border-fail bg-fail text-primary-foreground",
},
},
defaultVariants: { variant: "default" },
Expand Down
6 changes: 3 additions & 3 deletions editor-web/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: "border-white bg-white text-black hover:border-focus hover:bg-focus",
destructive: "border-fail bg-fail text-black hover:bg-transparent hover:text-fail",
outline: "border-input bg-transparent text-foreground hover:border-white",
default: "border-primary bg-primary text-primary-foreground hover:border-focus hover:bg-focus",
destructive: "border-fail bg-fail text-primary-foreground hover:bg-transparent hover:text-fail",
outline: "border-input bg-transparent text-foreground hover:border-primary",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:border-input",
ghost: "border-transparent bg-transparent text-muted-foreground hover:text-foreground",
link: "border-transparent text-name underline-offset-4 hover:underline",
Expand Down
2 changes: 1 addition & 1 deletion editor-web/src/components/ui/scroll-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function ScrollBar({ className, ...props }: React.ComponentProps<typeof ScrollAr
className={cn("flex w-[6px] touch-none select-none border-l border-transparent p-0 transition-opacity", className)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 bg-[#444] hover:bg-[#888]" />
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 bg-scrollbar hover:bg-scrollbar-hover" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
Expand Down
6 changes: 3 additions & 3 deletions editor-web/src/components/ui/slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ function Slider({
{...props}
>
<SliderPrimitive.Track className="relative h-[2px] w-full grow overflow-hidden bg-input">
<SliderPrimitive.Range className={cn("absolute h-full", modified ? "bg-focus" : "bg-white")} />
<SliderPrimitive.Range className={cn("absolute h-full", modified ? "bg-focus" : "bg-primary")} />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb
className={cn(
"block h-3 w-2 border-2 bg-black transition-colors focus-visible:outline-none focus-visible:border-focus disabled:pointer-events-none disabled:opacity-50",
modified ? "border-focus" : "border-white",
"block h-3 w-2 border-2 bg-background transition-colors focus-visible:outline-none focus-visible:border-focus disabled:pointer-events-none disabled:opacity-50",
modified ? "border-focus" : "border-primary",
)}
/>
</SliderPrimitive.Root>
Expand Down
2 changes: 1 addition & 1 deletion editor-web/src/components/ui/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPr
return (
<TabsPrimitive.Trigger
className={cn(
"-mb-[2px] inline-flex items-center justify-center gap-1.5 whitespace-nowrap border-b-2 border-transparent px-3 text-[11px] font-semibold uppercase tracking-[0.1em] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-white data-[state=active]:text-foreground",
"-mb-[2px] inline-flex items-center justify-center gap-1.5 whitespace-nowrap border-b-2 border-transparent px-3 text-[11px] font-semibold uppercase tracking-[0.1em] text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-primary data-[state=active]:text-foreground",
className,
)}
{...props}
Expand Down
Loading
Loading