refactor: Update bottom controls for improved styling and functionality

- Adjusted button styles across various components for consistency and better UX.
- Enhanced layout of action controls to utilize whitespace more effectively.
- Updated slider styles for a more modern appearance and improved usability.
- Refined input fields and labels for better accessibility and readability.
- Introduced new app surface styles for a cohesive design across the application.
- Added tests for canvas cursor behavior to ensure correct cursor display during operations.
This commit is contained in:
syntaxbullet
2026-07-11 14:55:32 +02:00
parent 37aa719047
commit 5915c62a9a
31 changed files with 345 additions and 287 deletions

View File

@@ -9,7 +9,6 @@ import { CommandPalette } from "./CommandPalette";
import { GenerateSheet } from "./GenerateSheet";
import { GenerationJobStatus } from "./GenerationJobStatus";
import { LayersSheet } from "./LayersSheet";
import { ShortcutsDisplay } from "./ShortcutsDisplay";
import { ToolOverlay } from "./ToolOverlay";
import { resolveTransformTargetBounds, selectedTransformTarget } from "@editor/transform-targets";
import type { AppState } from "@editor/state";
@@ -142,7 +141,7 @@ export function App({ app }: AppProps) {
const brushHint = brushUnavailableHint(document, { selection, tools, maskEdit });
return (
<main className="relative h-full overflow-hidden bg-[radial-gradient(circle_at_20%_18%,rgba(148,163,184,0.18),transparent_34%),radial-gradient(circle_at_82%_22%,rgba(71,85,105,0.22),transparent_36%),radial-gradient(circle_at_48%_88%,rgba(30,41,59,0.28),transparent_40%),linear-gradient(135deg,#020617_0%,#0f172a_46%,#111827_100%)] text-foreground">
<main className="relative h-full overflow-hidden bg-[radial-gradient(circle_at_50%_35%,rgba(51,65,85,0.28),transparent_48%),linear-gradient(145deg,#080b10_0%,#10151d_52%,#0a0e14_100%)] text-foreground">
{imageImport.input}
{project.input}
<CommandPalette
@@ -161,32 +160,32 @@ export function App({ app }: AppProps) {
closeLayers={closeLayers}
documentActions={app.actions.document}
/>
<header className="pointer-events-none absolute inset-x-3 top-3 z-10 flex h-20 items-center justify-between gap-4 rounded-full px-4 text-white backdrop-blur-xl">
<div className="min-w-0 pl-2">
<p className="text-[0.65rem] font-semibold uppercase tracking-[0.22em] text-white/45">Image Studio</p>
<h1 className="truncate text-sm font-semibold text-white/90" title={document.name}>{document.name}</h1>
<header className="app-surface pointer-events-none absolute inset-x-3 top-3 z-10 flex h-12 items-center justify-between gap-4 rounded-xl px-2.5 text-white">
<div className="min-w-0 px-1.5">
<p className="text-[0.58rem] font-semibold uppercase tracking-[0.18em] text-white/35">Image Studio</p>
<h1 className="truncate text-xs font-medium text-white/85" title={document.name}>{document.name}</h1>
</div>
<div className="pointer-events-auto flex items-center gap-2">
<button type="button" className="rounded-full border-0 bg-transparent p-0" onClick={openGenerate} aria-label="Open generation activity" title="Open generation activity">
<GenerationJobStatus generation={generation} compact />
</button>
<button type="button" className={topBarButtonClass()} onClick={imageImport.openFilePicker} aria-label="Open image" title="Open image (Cmd/Ctrl+O)">
<FolderOpen size={24} />
<FolderOpen size={18} />
</button>
<button type="button" className={topBarButtonClass()} onClick={project.openFilePicker} aria-label="Open project" title="Open project (Cmd/Ctrl+Shift+O)">
<FolderSimple size={24} />
<FolderSimple size={18} />
</button>
<button type="button" className={topBarButtonClass()} onClick={project.save} aria-label="Save project" title="Save project (Cmd/Ctrl+S)">
<FloppyDisk size={24} />
<FloppyDisk size={18} />
</button>
<button type="button" className={topBarButtonClass(generateOpen)} aria-label="Generate" aria-pressed={generateOpen} aria-controls="generate-sheet" onClick={toggleGenerate} title="Generate (G)">
<Sparkle size={24} weight={generateOpen ? "fill" : "regular"} />
<Sparkle size={18} weight={generateOpen ? "fill" : "regular"} />
</button>
<button type="button" className={topBarButtonClass()} aria-label="Export active artboard as PNG" title="Export active artboard as PNG" disabled={!activeArtboard} onClick={() => void app.actions.document.exportArtboard(activeArtboard?.id)}>
<DownloadSimple size={24} />
<DownloadSimple size={18} />
</button>
<button type="button" className={topBarButtonClass(layersOpen)} aria-label="Layers" aria-pressed={layersOpen} aria-controls="layers-sheet" onClick={toggleLayers} title="Layers (L)">
<Stack size={24} weight={layersOpen ? "fill" : "regular"} />
<Stack size={18} weight={layersOpen ? "fill" : "regular"} />
</button>
</div>
</header>
@@ -212,7 +211,7 @@ export function App({ app }: AppProps) {
dispatch={app.store.dispatch}
documentActions={app.actions.document}
/>
<div className="absolute inset-x-0 bottom-4 z-10 flex justify-center">
<div className="absolute inset-x-0 bottom-3 z-10 flex justify-center">
<BottomControlsIsland
document={document}
selection={selection}
@@ -236,17 +235,14 @@ export function App({ app }: AppProps) {
documentActions={app.actions.document}
/>
</div>
<div className="absolute bottom-4 left-4 z-10">
<ShortcutsDisplay />
</div>
<CanvasViewport store={app.store} />
</main>
);
}
function topBarButtonClass(active = false) {
const base = "inline-flex size-12 items-center justify-center rounded-full text-sm font-medium transition disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
return active ? `${base} bg-white text-black hover:bg-white hover:text-black` : `${base} text-white/75 hover:bg-white/10 hover:text-white`;
const base = "inline-flex size-8 items-center justify-center rounded-lg text-sm font-medium transition disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50";
return active ? `${base} bg-sky-300 text-slate-950` : `${base} text-white/60 hover:bg-white/[0.07] hover:text-white`;
}
type AppShellState = {

View File

@@ -50,18 +50,18 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
return (
<div
aria-hidden={!visible}
className={`flex min-h-20 min-w-96 rounded-[2rem] px-4 py-2 items-center justify-center gap-4 text-sm text-white backdrop-blur transition-all duration-200 ${
className={`app-surface subtle-scrollbar flex min-h-12 w-fit max-w-[calc(100vw-1.5rem)] min-w-0 flex-nowrap items-center justify-start gap-2 overflow-x-auto overflow-y-hidden rounded-xl px-2 py-1.5 text-xs text-white transition-all duration-200 ${
visible ? "pointer-events-auto translate-y-0 opacity-100" : "pointer-events-none translate-y-3 opacity-0"
}`}
>
{operation === "generate" ? (
<GenerateActionControls settings={generateSettings} generation={generation} dispatch={dispatch} workflow={generationWorkflow} generate={documentActions.generate} />
) : operation === "chromaKey" ? (
<ChromaKeyControls document={document} selection={selection} settings={chromaKeySettings} dispatch={dispatch} />
) : (activeTool === "brush" || activeTool === "eraser") && brushHint ? (
<BrushHint tool={activeTool} hint={brushHint} />
) : activeTool === "brush" || activeTool === "eraser" ? (
<BrushControls tool={activeTool} settings={brushSettings} editingMask={editingMask} maskViewMode={maskViewMode} dispatch={dispatch} />
) : operation === "chromaKey" ? (
<ChromaKeyControls document={document} selection={selection} settings={chromaKeySettings} dispatch={dispatch} />
) : activeTool === "magicWand" ? (
<MagicWandControls settings={magicWandSettings} dispatch={dispatch} />
) : transformBounds && transformTarget ? (
@@ -78,7 +78,7 @@ export function BottomControlsIsland({ document, selection, viewport, visible, a
function BrushHint({ tool, hint }: { tool: "brush" | "eraser"; hint: string }) {
return (
<div className="flex items-center gap-3 px-4 text-center">
<span className="rounded-full bg-white/10 px-3 py-1 text-xs font-medium uppercase tracking-wide text-white/55">{tool}</span>
<span className="rounded bg-white/[0.06] px-2 py-0.5 text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-white/45">{tool}</span>
<span className="max-w-[34rem] text-sm font-medium text-white/80">{hint}</span>
</div>
);

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef } from "react";
import type { ImageDocument } from "@core/document";
import type { AppState, MaskEditState } from "@editor/state";
import { isOperationWorkspacePanel, type AppState, type MaskEditState, type WorkspacePanel } from "@editor/state";
import type { AppStore } from "@editor/store";
import type { InteractionMode } from "@editor/tools";
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
@@ -40,7 +40,13 @@ export function CanvasViewport({
useCanvasInput(canvasRef, store, inputOptions);
const brushHint = brushUnavailableHint(cursorState.document, cursorState.editor);
const hasBrushPreview = Boolean(cursorState.hasBrushPreview && !brushHint && canPreviewBrush(cursorState.document, cursorState.editor));
const cursorClass = canvasCursorClass(cursorState.editor.tools.interactionMode, cursorState.isPanning, hasBrushPreview, !brushHint);
const cursorClass = canvasCursorClass(
cursorState.editor.tools.interactionMode,
cursorState.isPanning,
hasBrushPreview,
!brushHint,
isOperationWorkspacePanel(cursorState.panel),
);
return (
<canvas
@@ -57,6 +63,7 @@ type CanvasCursorState = {
editor: BrushTargetEditorState;
hasBrushPreview: boolean;
isPanning: boolean;
panel: WorkspacePanel;
};
function selectCanvasCursorState(state: AppState): CanvasCursorState {
@@ -72,6 +79,7 @@ function selectCanvasCursorState(state: AppState): CanvasCursorState {
},
hasBrushPreview: Boolean(state.editor.brushPreview),
isPanning: state.editor.pointerSession?.type === "pan",
panel: state.editor.workspace.panel,
};
}
@@ -80,6 +88,7 @@ function canvasCursorStatesEqual(a: CanvasCursorState, b: CanvasCursorState): bo
a.document === b.document &&
a.hasBrushPreview === b.hasBrushPreview &&
a.isPanning === b.isPanning &&
a.panel === b.panel &&
a.editor.selection === b.editor.selection &&
a.editor.tools.activeTool === b.editor.tools.activeTool &&
interactionModesEqual(a.editor.tools.interactionMode, b.editor.tools.interactionMode) &&

View File

@@ -151,7 +151,7 @@ export function CommandPalette({
return (
<div
className="pointer-events-auto absolute inset-0 z-50 flex items-start justify-center bg-black/20 px-3 pt-[10vh] backdrop-blur-sm"
className="pointer-events-auto absolute inset-0 z-50 flex items-start justify-center bg-black/35 px-3 pt-[12vh] backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="Command palette"
@@ -159,9 +159,9 @@ export function CommandPalette({
if (event.target === event.currentTarget) close();
}}
>
<div className="w-full max-w-2xl overflow-hidden rounded-2xl bg-slate-950/[0.92] text-white shadow-2xl ring-1 ring-white/[0.12]">
<div className="flex h-16 items-center gap-3 border-b border-white/10 px-4">
<MagnifyingGlass size={22} className="shrink-0 text-white/45" />
<div className="app-surface w-full max-w-xl overflow-hidden rounded-xl text-white shadow-2xl">
<div className="flex h-12 items-center gap-2.5 border-b border-white/[0.07] px-3">
<MagnifyingGlass size={18} className="shrink-0 text-white/35" />
<input
ref={inputRef}
value={state.query}
@@ -169,9 +169,9 @@ export function CommandPalette({
onKeyDown={handleInputKeyDown}
placeholder="Search commands"
aria-label="Search commands"
className="h-full min-w-0 flex-1 bg-transparent text-base text-white outline-none placeholder:text-white/30"
className="h-full min-w-0 flex-1 bg-transparent text-sm text-white outline-none placeholder:text-white/25"
/>
<span className="hidden items-center gap-1 rounded-full bg-white/[0.07] px-2 py-1 text-xs font-semibold text-white/45 sm:inline-flex">
<span className="hidden items-center gap-1 rounded bg-white/[0.05] px-1.5 py-0.5 text-[0.65rem] font-semibold text-white/40 sm:inline-flex">
<Command size={14} weight="bold" /> K
</span>
</div>
@@ -219,15 +219,15 @@ function filterItems(items: PaletteItem[], query: string) {
}
function paletteItemClass(active: boolean, disabled: boolean) {
const base = "flex min-h-14 w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition focus:outline-none";
const base = "flex min-h-11 w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left transition focus:outline-none";
if (disabled) return `${base} cursor-not-allowed text-white/30 opacity-45`;
return active ? `${base} bg-white text-black` : `${base} text-white/75 hover:bg-white/[0.08] hover:text-white`;
}
function paletteIconClass(active: boolean) {
return active
? "grid size-10 shrink-0 place-items-center rounded-xl bg-black/10 text-black/60"
: "grid size-10 shrink-0 place-items-center rounded-xl bg-white/[0.06] text-white/60";
? "grid size-8 shrink-0 place-items-center rounded-md bg-black/10 text-black/60"
: "grid size-8 shrink-0 place-items-center rounded-md bg-white/[0.05] text-white/55";
}
function paletteSubtitleClass(active: boolean) {
@@ -236,6 +236,6 @@ function paletteSubtitleClass(active: boolean) {
function paletteSectionClass(active: boolean) {
return active
? "shrink-0 rounded-full bg-black/10 px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-black/45"
: "shrink-0 rounded-full bg-white/[0.06] px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-white/35";
? "shrink-0 rounded bg-black/10 px-1.5 py-0.5 text-[0.6rem] font-semibold uppercase tracking-[0.12em] text-black/45"
: "shrink-0 rounded bg-white/[0.05] px-1.5 py-0.5 text-[0.6rem] font-semibold uppercase tracking-[0.12em] text-white/30";
}

View File

@@ -16,7 +16,7 @@ export function GenerateSheet({ settings, resources, open, dispatch }: GenerateS
id="generate-sheet"
aria-hidden={!open}
aria-label="Generate settings"
className={`pointer-events-auto absolute bottom-6 right-4 top-24 z-20 flex w-[28rem] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-[2.5rem] px-4 text-sm text-white backdrop-blur-xl transition-all duration-200 ${
className={`app-surface pointer-events-auto absolute bottom-3 right-3 top-[4.5rem] z-20 flex w-[22rem] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-xl px-3 text-sm text-white transition-all duration-200 ${
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
}`}
>

View File

@@ -20,7 +20,7 @@ export function GenerationJobStatus({ generation, compact = false }: { generatio
const label = job.status === "running" ? `${job.label} ${formatElapsed(elapsed)}` : job.status === "failed" ? job.error ?? `${job.label} failed` : job.status === "cancelled" ? `${job.label} cancelled` : `${job.label} complete`;
const tone = job.status === "failed" ? "bg-red-500/15 text-red-100" : job.status === "running" ? "bg-white/10 text-white/70" : job.status === "cancelled" ? "bg-amber-500/15 text-amber-100" : "bg-emerald-500/15 text-emerald-100";
return <span className={`${compact ? "max-w-56" : "max-w-80"} truncate rounded-full px-3 py-2 text-xs font-medium ${tone}`} title={label} aria-live="polite">{label}</span>;
return <span className={`${compact ? "max-w-48" : "max-w-72"} truncate rounded-md px-2 py-1 text-[0.68rem] font-medium ${tone}`} title={label} aria-live="polite">{label}</span>;
}
function formatElapsed(seconds: number) {

View File

@@ -17,6 +17,9 @@ import { addAdjustmentLayer, addArtboard, addEmptyLayer, addGroupLayer, addLayer
import { MaskOperationButtons, MaskStatus } from "./layers/MaskControls";
import { LayerThumbnail } from "./layers/LayerThumbnail";
import { createLayerThumbnailIndex, type LayerThumbnailModel } from "./layers/thumbnailModel";
import { BottomControlColorPicker } from "./bottom-controls/ColorPicker";
import { BottomControlSlider } from "./bottom-controls/Slider";
import { BottomControlSelectMenu, type BottomControlSelectOption } from "./bottom-controls/SelectMenu";
export type LayersSheetProps = {
document: ImageDocument;
@@ -36,7 +39,7 @@ export function LayersSheet({ document, selection, maskEdit, open, dispatch, doc
id="layers-sheet"
aria-hidden={!open}
aria-label="Layers"
className={`pointer-events-auto absolute bottom-6 right-4 top-24 z-20 flex w-[28rem] flex-col overflow-hidden rounded-[2.5rem] px-4 text-sm text-white backdrop-blur-xl transition-all duration-200 ${
className={`app-surface pointer-events-auto absolute bottom-3 right-3 top-[4.5rem] z-20 flex w-[22rem] flex-col overflow-hidden rounded-xl px-3 text-xs text-white transition-all duration-200 ${
open ? "translate-x-0 opacity-100" : "pointer-events-none translate-x-8 opacity-0"
}`}
>
@@ -81,14 +84,14 @@ function LayersSheetBody({
return (
<>
<header className="flex h-20 items-center">
<header className="flex h-14 items-center border-b border-white/[0.06]">
<div className="flex w-full items-center gap-2">
<button type="button" className={labeledToolbarButtonClass()} aria-label="Add artboard" title="Add artboard" onClick={() => addArtboard(document, dispatch)}>
<span className="grid w-12 flex-none place-items-center"><Plus size={24} /></span>
<span className="grid w-8 flex-none place-items-center"><Plus size={17} /></span>
<span className="min-w-0 flex-1 text-center">Artboard</span>
</button>
<button type="button" className={labeledToolbarButtonClass()} aria-label="Add layer" title="Add layer" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addEmptyLayer(document, selectedArtboardId, selectedLayer, dispatch)}>
<span className="grid w-12 flex-none place-items-center"><Plus size={24} /></span>
<span className="grid w-8 flex-none place-items-center"><Plus size={17} /></span>
<span className="min-w-0 flex-1 text-center">Layer</span>
</button>
<button type="button" className={toolbarButtonClass()} aria-label="Add group" title="Add group" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addGroupLayer(selectedArtboardId, dispatch)}>
@@ -100,7 +103,7 @@ function LayersSheetBody({
<button type="button" className={toolbarButtonClass()} aria-label="Add text" title="Add text layer" disabled={!selectedArtboardId} onClick={() => selectedArtboardId && addTextLayer(document, selectedArtboardId, selectedLayer, dispatch)}><TextT size={24} /></button>
</div>
</header>
<div className="mb-4 grid grid-cols-5 gap-2">
<div className="my-2 grid grid-cols-5 gap-1 border-b border-white/[0.06] pb-2">
<button type="button" className={toolbarButtonClass()} aria-label="Group selected" title="Group selected" disabled={!canGroup} onClick={() => selection.artboardId && groupLayers(selection.artboardId, selection.layerIds, dispatch)}>
<Stack size={24} />
</button>
@@ -124,9 +127,9 @@ function LayersSheetBody({
const displayLayerCount = documentIndex.displayLayerCountByArtboardId.get(artboard.id) ?? 0;
return (
<section key={artboard.id} className="mb-5 last:mb-0">
<section key={artboard.id} className="mb-3 last:mb-0">
<div
className={`flex h-12 w-full items-center gap-3 rounded-full px-4 text-left transition ${selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "bg-white text-black" : "text-white/70 hover:bg-white/[0.06] hover:text-white"}`}
className={`flex h-9 w-full items-center gap-2 rounded-lg px-2 text-left transition ${selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "bg-sky-300 text-slate-950" : "text-white/70 hover:bg-white/[0.06] hover:text-white"}`}
onDragOver={(event) => {
if (draggedLayerId.current) event.preventDefault();
}}
@@ -174,11 +177,11 @@ function LayersSheetBody({
>
<DownloadSimple size={24} weight="regular" />
</button>
<span className={selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "min-w-8 rounded-full bg-black/10 px-2 py-1 text-center text-xs text-black/45" : "min-w-8 rounded-full bg-white/10 px-2 py-1 text-center text-xs text-white/45"}>{displayLayerCount}</span>
<span className={selection.artboardId === artboard.id && selection.layerIds.length === 0 ? "min-w-6 rounded bg-black/10 px-1.5 py-0.5 text-center text-[0.65rem] text-black/45" : "min-w-6 rounded bg-white/[0.06] px-1.5 py-0.5 text-center text-[0.65rem] text-white/40"}>{displayLayerCount}</span>
</div>
<div className="mt-2 space-y-2 pl-5">
<div className="mt-1 space-y-1 pl-3">
{displayLayerCount === 0 ? (
<div className="rounded-[1.5rem] border border-dashed border-white/10 px-4 py-5 text-center text-white/35">No layers yet</div>
<div className="rounded-lg border border-dashed border-white/10 px-3 py-3 text-center text-white/30">No layers yet</div>
) : (
artboard.layers.map((layer) => (
<LayerRow
@@ -211,24 +214,49 @@ function LayersSheetBody({
function TextInspector({ layer, dispatch }: { layer: TextLayer; dispatch: AppStore["dispatch"] }) {
const [content, setContent] = useState(layer.content);
const [style, setStyle] = useState<TextStyle>({ ...layer.style });
useEffect(() => { setContent(layer.content); setStyle({ ...layer.style }); }, [layer.id, layer.content, layer.style]);
const commit = () => dispatch(commandIds.documentSetTextLayer, { layerId: layer.id, content, style });
return <section aria-label="Text settings" className="mb-3 space-y-2 rounded-[1.5rem] bg-white/[0.05] p-3">
<textarea aria-label="Text content" className="w-full resize-y rounded-xl bg-black/25 p-2 text-sm outline-none ring-1 ring-white/10" rows={2} value={content} disabled={layer.locked} onChange={(event) => setContent(event.target.value)} onBlur={commit} onKeyDown={(event) => event.stopPropagation()} />
const [fontSizeDraft, setFontSizeDraft] = useState(String(layer.style.fontSize));
const [lineHeightDraft, setLineHeightDraft] = useState(String(layer.style.lineHeight));
useEffect(() => { setContent(layer.content); setStyle({ ...layer.style }); setFontSizeDraft(String(layer.style.fontSize)); setLineHeightDraft(String(layer.style.lineHeight)); }, [layer.id, layer.content, layer.style]);
const updateText = (nextContent: string) => {
setContent(nextContent);
dispatch(commandIds.documentSetTextLayer, { layerId: layer.id, content: nextContent, style });
};
const updateStyle = (nextStyle: TextStyle) => {
setStyle(nextStyle);
dispatch(commandIds.documentSetTextLayer, { layerId: layer.id, content, style: nextStyle });
};
const commitNumber = (field: "fontSize" | "lineHeight", draft: string, reset: (value: string) => void) => {
const next = Number(draft.trim());
if (draft.trim() === "" || !Number.isFinite(next)) { reset(String(layer.style[field])); return; }
const nextStyle = { ...style, [field]: next };
setStyle(nextStyle);
dispatch(commandIds.documentSetTextLayer, { layerId: layer.id, content, style: nextStyle });
};
return <section aria-label="Text settings" className="mb-2 space-y-2 border-b border-white/[0.07] px-0.5 pb-3">
<textarea aria-label="Text content" className="w-full resize-y rounded-md bg-white/[0.035] p-2 text-xs outline-none ring-1 ring-white/[0.07] focus:ring-sky-300/50" rows={2} value={content} disabled={layer.locked} onChange={(event) => updateText(event.target.value)} onKeyDown={(event) => event.stopPropagation()} />
<div className="grid grid-cols-3 gap-2">
<select aria-label="Font family" className={textFieldClass()} value={style.fontFamily} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontFamily: event.target.value as TextStyle["fontFamily"] })} onBlur={commit}>{builtInTextFonts.map((font) => <option key={font}>{font}</option>)}</select>
<input aria-label="Font size" className={textFieldClass()} type="number" min="1" max="1000" value={style.fontSize} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontSize: Number(event.target.value) })} onBlur={commit} />
<input aria-label="Text color" className={textFieldClass()} type="color" value={style.color} disabled={layer.locked} onChange={(event) => setStyle({ ...style, color: event.target.value })} onBlur={commit} />
<select aria-label="Font weight" className={textFieldClass()} value={style.fontWeight} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontWeight: Number(event.target.value) as 400 | 700 })} onBlur={commit}><option value="400">Regular</option><option value="700">Bold</option></select>
<select aria-label="Font style" className={textFieldClass()} value={style.fontStyle} disabled={layer.locked} onChange={(event) => setStyle({ ...style, fontStyle: event.target.value as TextStyle["fontStyle"] })} onBlur={commit}><option value="normal">Normal</option><option value="italic">Italic</option></select>
<select aria-label="Text alignment" className={textFieldClass()} value={style.alignment} disabled={layer.locked} onChange={(event) => setStyle({ ...style, alignment: event.target.value as TextStyle["alignment"] })} onBlur={commit}><option value="left">Left</option><option value="center">Center</option><option value="right">Right</option></select>
<input aria-label="Line height" className={textFieldClass()} type="number" min="0.5" max="5" step="0.1" value={style.lineHeight} disabled={layer.locked} onChange={(event) => setStyle({ ...style, lineHeight: Number(event.target.value) })} onBlur={commit} />
<InspectorSelect value={style.fontFamily} options={fontOptions} ariaLabel="Font family" onValueChange={(fontFamily) => updateStyle({ ...style, fontFamily })} />
<input aria-label="Font size" className={textFieldClass()} type="text" inputMode="numeric" value={fontSizeDraft} disabled={layer.locked} onFocus={(event) => event.currentTarget.select()} onChange={(event) => { const draft = event.target.value; setFontSizeDraft(draft); const next = Number(draft); if (draft.trim() && Number.isFinite(next)) updateStyle({ ...style, fontSize: next }); }} onBlur={() => commitNumber("fontSize", fontSizeDraft, setFontSizeDraft)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") { setFontSizeDraft(String(layer.style.fontSize)); event.currentTarget.blur(); } }} />
<BottomControlColorPicker value={style.color} aria-label="Text color" onValueChange={(color) => updateStyle({ ...style, color })} />
<InspectorSelect value={String(style.fontWeight)} options={weightOptions} ariaLabel="Font weight" onValueChange={(fontWeight) => updateStyle({ ...style, fontWeight: Number(fontWeight) as 400 | 700 })} />
<InspectorSelect value={style.fontStyle} options={styleOptions} ariaLabel="Font style" onValueChange={(fontStyle) => updateStyle({ ...style, fontStyle })} />
<InspectorSelect value={style.alignment} options={alignmentOptions} ariaLabel="Text alignment" onValueChange={(alignment) => updateStyle({ ...style, alignment })} />
<input aria-label="Line height" className={textFieldClass()} type="text" inputMode="decimal" value={lineHeightDraft} disabled={layer.locked} onFocus={(event) => event.currentTarget.select()} onChange={(event) => { const draft = event.target.value; setLineHeightDraft(draft); const next = Number(draft); if (draft.trim() && Number.isFinite(next)) updateStyle({ ...style, lineHeight: next }); }} onBlur={() => commitNumber("lineHeight", lineHeightDraft, setLineHeightDraft)} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") { setLineHeightDraft(String(layer.style.lineHeight)); event.currentTarget.blur(); } }} />
</div>
<p className="text-[10px] text-white/40">Built-in system fonts only. Missing fonts fall back to Arial/sans-serif; exact glyph shapes can vary by platform.</p>
</section>;
}
function textFieldClass() { return "min-w-0 rounded-lg bg-black/25 px-2 py-1.5 text-xs text-white outline-none ring-1 ring-white/10"; }
const fontOptions = builtInTextFonts.map((font) => ({ value: font, label: font }));
const weightOptions = [{ value: "400", label: "Regular" }, { value: "700", label: "Bold" }] as const;
const styleOptions = [{ value: "normal", label: "Normal" }, { value: "italic", label: "Italic" }] as const;
const alignmentOptions = [{ value: "left", label: "Left" }, { value: "center", label: "Center" }, { value: "right", label: "Right" }] as const;
function InspectorSelect<T extends string>({ value, options, ariaLabel, onValueChange }: { value: T; options: readonly BottomControlSelectOption<T>[]; ariaLabel: string; onValueChange: (value: T) => void }) {
return <BottomControlSelectMenu value={value} options={options} aria-label={ariaLabel} placement="inline" onValueChange={onValueChange} />;
}
function textFieldClass() { return "h-7 min-w-0 rounded bg-black/20 px-1.5 font-mono text-xs text-white outline-none ring-1 ring-white/[0.07] focus:ring-sky-300/50"; }
function AdjustmentInspector({ layer, dispatch }: { layer: Extract<Layer, { type: "adjustment" }>; dispatch: AppStore["dispatch"] }) {
const [draft, setDraft] = useState<ColorAdjustment>(() => cloneAdjustment(layer.adjustment));
@@ -243,13 +271,8 @@ function AdjustmentInspector({ layer, dispatch }: { layer: Extract<Layer, { type
["Blue", "blue", draft.colorBalance.blue],
] as const;
const commit = () => {
if (JSON.stringify(draft) === JSON.stringify(layer.adjustment)) return;
dispatch(commandIds.documentSetAdjustment, { layerId: layer.id, adjustment: draft });
};
return (
<section aria-label="Adjustment settings" className="mb-3 rounded-[1.5rem] bg-white/[0.05] p-3">
<section aria-label="Adjustment settings" className="mb-2 border-b border-white/[0.07] px-0.5 pb-3">
<p className="mb-2 text-xs text-white/45">
Affects visible artboard layers beneath it. Adjustment layers stay at artboard level.
</p>
@@ -257,17 +280,19 @@ function AdjustmentInspector({ layer, dispatch }: { layer: Extract<Layer, { type
{fields.map(([label, key, value]) => (
<label key={key} className="text-[0.7rem] text-white/55">
<span>{label}</span>
<input
className="mt-1 w-full accent-violet-300"
type="range"
min="-1"
max="1"
step="0.01"
<BottomControlSlider
className="mt-1 w-full"
min={-1}
max={1}
step={0.01}
value={value}
disabled={layer.locked}
onChange={(event) => setDraft(withAdjustmentValue(draft, key, Number(event.currentTarget.value)))}
onPointerUp={commit}
onBlur={commit}
aria-label={`${label} adjustment`}
onValueChange={(value) => {
const next = withAdjustmentValue(draft, key, value);
setDraft(next);
dispatch(commandIds.documentSetAdjustment, { layerId: layer.id, adjustment: next });
}}
/>
</label>
))}
@@ -331,7 +356,7 @@ function LayerRow({
<div>
<div
draggable
className={`group flex h-12 w-full items-center gap-3 rounded-full px-4 text-left transition ${editingMask ? "bg-sky-300 text-black" : selected ? "bg-white text-black" : "text-white/75 hover:bg-white/[0.07] hover:text-white"}`}
className={`group flex h-10 w-full items-center gap-2 rounded-lg px-2 text-left transition ${editingMask ? "bg-sky-300 text-slate-950" : selected ? "bg-white text-slate-950" : "text-white/70 hover:bg-white/[0.06] hover:text-white"}`}
style={{ paddingLeft: rowPadding }}
onDragStart={(event) => {
event.dataTransfer.effectAllowed = "move";
@@ -383,13 +408,13 @@ function LayerRow({
</button>
)}
{layerMask ? (
<span className={`inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs ${editingMask || selected ? "bg-black/10 text-black/65" : "bg-sky-400/10 text-sky-100/70"}`}>
<span className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-[0.68rem] ${editingMask || selected ? "bg-black/10 text-black/65" : "bg-sky-400/10 text-sky-100/70"}`}>
<Stack size={13} weight="fill" /> Mask
</span>
) : canAddMask && layerInfo ? (
<button
type="button"
className={editingMask || selected ? "rounded-full bg-black/10 px-3 py-1 text-xs text-black/65 transition hover:bg-black/15" : "rounded-full bg-white/5 px-3 py-1 text-xs text-white/45 transition hover:bg-sky-400/15 hover:text-sky-100"}
className={editingMask || selected ? "rounded bg-black/10 px-2 py-0.5 text-[0.68rem] text-black/65 transition hover:bg-black/15" : "rounded bg-white/[0.04] px-2 py-0.5 text-[0.68rem] text-white/45 transition hover:bg-sky-400/15 hover:text-sky-100"}
onClick={() => addLayerMask(documentIndex, layerInfo, dispatch)}
>
Add mask
@@ -400,7 +425,7 @@ function LayerRow({
</button>
</div>
{layerMask ? (
<div role="group" aria-label={`Mask attached to ${layer.name}${editingMask ? ", currently editing" : ""}`} className={`relative mt-2 ml-10 flex min-h-14 flex-wrap items-center gap-2 rounded-[1.5rem] border-l-2 py-2 pl-4 pr-2 text-sm transition ${editingMask ? "border-sky-300 bg-sky-300/15 text-sky-50 ring-1 ring-inset ring-sky-300/30" : "border-sky-300/30 text-sky-100/70 hover:bg-white/[0.04]"}`}>
<div role="group" aria-label={`Mask attached to ${layer.name}${editingMask ? ", currently editing" : ""}`} className={`relative mt-1 ml-8 flex min-h-11 flex-wrap items-center gap-1.5 border-l-2 py-1.5 pl-3 pr-1.5 text-xs transition ${editingMask ? "border-sky-300 text-sky-50" : "border-sky-300/25 text-sky-100/65"}`}>
<span aria-hidden="true" className="absolute -left-2 top-1/2 h-px w-2 bg-sky-300/40" />
{maskThumbnail ? <LayerThumbnail model={maskThumbnail} label={`${layer.name} mask preview`} /> : <Stack size={24} weight="fill" className="shrink-0" />}
<span className="min-w-28 flex-1 truncate font-medium">{maskLayer ? editingMask ? "Editing layer mask" : "Layer mask" : "Layer mask missing"}</span>
@@ -409,7 +434,7 @@ function LayerRow({
<div className="flex flex-wrap items-center gap-1">
<button
type="button"
className={`h-9 rounded-full px-4 text-sm font-medium transition ${editingMask ? "bg-sky-300 text-black" : "text-sky-100/75 hover:bg-white/10 hover:text-sky-50"}`}
className={`h-8 rounded-md px-3 text-xs font-medium transition ${editingMask ? "bg-sky-300 text-black" : "text-sky-100/70 hover:bg-white/[0.07] hover:text-sky-50"}`}
onClick={() =>
editingMask
? dispatch(commandIds.toolExitMaskEdit, undefined)
@@ -447,7 +472,7 @@ function LayerRow({
) : null}
<button
type="button"
className="h-9 rounded-full px-4 text-sm font-medium text-sky-100/55 transition hover:bg-red-400/15 hover:text-red-100"
className="h-8 rounded-md px-3 text-xs font-medium text-sky-100/50 transition hover:bg-red-400/15 hover:text-red-100"
onClick={() => dispatch(commandIds.documentRemoveLayerMask, { layerId: layer.id })}
>
Remove
@@ -487,7 +512,7 @@ function RenameInput({ value, onChange, onCommit, onCancel }: { value: string; o
<input
autoFocus
aria-label="Rename item"
className="min-w-0 flex-1 rounded-full bg-white px-3 py-1.5 font-medium text-black outline-none ring-1 ring-black/10 focus:ring-sky-300/50"
className="min-w-0 flex-1 rounded-md bg-white px-2.5 py-1 font-medium text-black outline-none ring-1 ring-black/10 focus:ring-sky-300/50"
value={value}
onChange={(event) => onChange(event.target.value)}
onBlur={onCommit}
@@ -521,13 +546,13 @@ function dropLayer(
}
function toolbarButtonClass() {
return "inline-flex size-12 items-center justify-center rounded-full text-white/75 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
return "inline-flex size-8 items-center justify-center rounded-lg text-white/60 transition hover:bg-white/[0.07] hover:text-white disabled:pointer-events-none disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50 [&>svg]:size-[17px]";
}
function labeledToolbarButtonClass() {
return "inline-flex h-12 flex-1 items-center rounded-full pr-4 text-sm font-medium text-white/75 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35 focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
return "inline-flex h-8 flex-1 items-center rounded-lg pr-2 text-xs font-medium text-white/65 transition hover:bg-white/[0.07] hover:text-white disabled:pointer-events-none disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50";
}
function maskActionButtonClass() {
return "rounded-full bg-white/5 px-2.5 py-1 text-[0.7rem] font-semibold text-white/60 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35";
return "rounded-md bg-white/[0.05] px-2 py-1 text-[0.68rem] font-semibold text-white/55 transition hover:bg-white/[0.09] hover:text-white disabled:pointer-events-none disabled:opacity-35";
}

View File

@@ -1,77 +0,0 @@
import { useState } from "react";
import { ArrowFatLineUp, Backspace, CaretDown, Command } from "@phosphor-icons/react";
type Shortcut = {
keys: string[];
label: string;
};
const shortcuts: Shortcut[] = [
{ keys: ["S"], label: "Select" },
{ keys: ["G"], label: "Generate" },
{ keys: ["B"], label: "Brush" },
{ keys: ["E"], label: "Eraser" },
{ keys: ["K"], label: "Chroma key" },
{ keys: ["W"], label: "Magic wand" },
{ keys: ["⇧", "Click"], label: "Wand add" },
{ keys: ["Alt", "Click"], label: "Wand subtract" },
{ keys: ["P"], label: "Pan" },
{ keys: ["Space"], label: "Hold to pan" },
{ keys: ["L"], label: "Layers" },
{ keys: ["⌘", "K"], label: "Commands" },
{ keys: ["⌘", "O"], label: "Open image" },
{ keys: ["⌘", "Z"], label: "Undo" },
{ keys: ["⇧", "⌘", "Z"], label: "Redo" },
{ keys: ["Del", "⌫"], label: "Delete" },
];
export function ShortcutsDisplay() {
const [open, setOpen] = useState(true);
return (
<aside
aria-label="Keyboard shortcuts"
className="pointer-events-auto hidden w-[30rem] rounded-[2rem] p-3 text-white backdrop-blur-xl sm:block"
>
<button
type="button"
className="flex h-12 w-full items-center justify-between rounded-full px-5 text-left transition hover:bg-white/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30"
aria-expanded={open}
onClick={() => setOpen((current) => !current)}
>
<span className="text-[0.65rem] font-semibold uppercase tracking-[0.22em] text-white/45">Shortcuts</span>
<CaretDown size={14} className={`text-white/40 transition-transform ${open ? "rotate-0" : "-rotate-90"}`} weight="bold" />
</button>
<dl className={`mt-2 grid grid-cols-2 gap-x-4 gap-y-1 overflow-hidden rounded-[1.5rem] transition-all duration-200 ${open ? "max-h-96 p-1 opacity-100" : "mt-0 max-h-0 p-0 opacity-0"}`}>
{shortcuts.map((shortcut) => (
<div key={`${shortcut.keys.join("+")}-${shortcut.label}`} className="flex h-8 items-center justify-between gap-3 rounded-full px-2">
<dt className="truncate text-xs font-medium text-white/60">{shortcut.label}</dt>
<dd className="flex shrink-0 items-center gap-2">
{shortcut.keys.map((key, index) => (
<span key={key} className="inline-flex items-center gap-2">
{shortcut.label === "Delete" && index > 0 ? <span className="h-3 w-px bg-white/20" aria-hidden="true" /> : null}
<kbd className="inline-flex h-8 min-w-8 items-center justify-center rounded-full bg-white/5 px-3 text-sm font-semibold leading-none text-white/75">
{keyIcon(key) ?? key}
</kbd>
</span>
))}
</dd>
</div>
))}
</dl>
</aside>
);
}
function keyIcon(key: string) {
switch (key) {
case "⌘":
return <Command size={18} weight="bold" />;
case "⇧":
return <ArrowFatLineUp size={18} weight="bold" />;
case "⌫":
return <Backspace size={18} weight="bold" />;
default:
return undefined;
}
}

View File

@@ -3,7 +3,7 @@ import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import type { InteractionMode, OperationId, ToolId } from "@editor/tools";
import { availableOperationIds, availableToolIds } from "@editor/tools";
import type { WorkspacePanel } from "@editor/state";
import { isOperationWorkspacePanel, type WorkspacePanel } from "@editor/state";
import { labelForTool } from "./toolLabels";
export type ToolOverlayProps = {
@@ -17,10 +17,10 @@ export function ToolOverlay({ activeTool, interactionMode, panel, dispatch }: To
return (
<nav
aria-label="Tools"
className="pointer-events-auto flex w-20 flex-col items-center gap-3 rounded-full p-2 text-white backdrop-blur-xl"
className="app-surface pointer-events-auto flex w-11 flex-col items-center gap-1 rounded-xl p-1 text-white"
>
{availableToolIds.map((tool) => {
const active = isToolHighlighted(tool, activeTool, interactionMode);
const active = !isOperationWorkspacePanel(panel) && isToolHighlighted(tool, activeTool, interactionMode);
const Icon = iconForTool(tool);
return (
@@ -33,11 +33,11 @@ export function ToolOverlay({ activeTool, interactionMode, panel, dispatch }: To
className={buttonClass(active)}
onClick={() => dispatch(commandIds.toolSetActive, { tool })}
>
<Icon size={24} weight={active ? "fill" : "regular"} />
<Icon size={18} weight={active ? "fill" : "regular"} />
</button>
);
})}
<div className="h-px w-8 bg-white/15" aria-hidden="true" />
<div className="my-0.5 h-px w-6 bg-white/10" aria-hidden="true" />
{availableOperationIds.map((operation) => {
const active = panel === operation;
const Icon = iconForOperation(operation);
@@ -51,7 +51,7 @@ export function ToolOverlay({ activeTool, interactionMode, panel, dispatch }: To
className={buttonClass(active)}
onClick={() => dispatch(commandIds.workspaceSetPanel, { panel: active ? "none" : operation })}
>
<Icon size={24} weight={active ? "fill" : "regular"} />
<Icon size={18} weight={active ? "fill" : "regular"} />
</button>
);
})}
@@ -88,6 +88,6 @@ function isToolHighlighted(tool: ToolId, activeTool: ToolId, interactionMode: In
}
function buttonClass(active: boolean) {
const base = "inline-flex size-12 items-center justify-center rounded-full text-xs font-medium transition focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
return active ? `${base} bg-white text-black hover:bg-white hover:text-black` : `${base} text-white/75 hover:bg-white/10 hover:text-white`;
const base = "inline-flex size-9 items-center justify-center rounded-lg text-xs font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50";
return active ? `${base} bg-sky-300 text-slate-950` : `${base} text-white/55 hover:bg-white/[0.07] hover:text-white`;
}

View File

@@ -54,11 +54,11 @@ export function BrushControls({ tool, settings, editingMask = false, maskViewMod
min={1}
max={200}
value={settings.size}
className="w-48"
className="min-w-10 w-[clamp(2.5rem,9vw,7rem)] shrink"
aria-label={`${tool} size`}
onValueChange={(size) => dispatch(commandIds.toolSetBrushSettings, { size })}
/>
<span className="w-12 text-right text-base text-white">{Math.round(settings.size)}</span>
<span className="w-9 text-right text-xs font-medium text-white/85">{Math.round(settings.size)}</span>
</label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}>
@@ -67,11 +67,11 @@ export function BrushControls({ tool, settings, editingMask = false, maskViewMod
min={0}
max={100}
value={settings.hardness}
className="w-48"
className="min-w-10 w-[clamp(2.5rem,9vw,7rem)] shrink"
aria-label={`${tool} hardness`}
onValueChange={(hardness) => dispatch(commandIds.toolSetBrushSettings, { hardness })}
/>
<span className="w-12 text-right text-base text-white">{Math.round(settings.hardness)}</span>
<span className="w-9 text-right text-xs font-medium text-white/85">{Math.round(settings.hardness)}</span>
</label>
{editingMask ? (
<>
@@ -88,7 +88,7 @@ export function BrushControls({ tool, settings, editingMask = false, maskViewMod
<BottomControlDivider />
<button
type="button"
className="rounded-full bg-white px-5 py-2 text-base font-medium text-black transition hover:bg-white/90"
className="rounded-md bg-sky-300 px-3 py-1.5 text-xs font-semibold text-slate-950 transition hover:bg-sky-200"
onClick={() => dispatch(commandIds.toolExitMaskEdit, undefined)}
>
Done
@@ -100,5 +100,5 @@ export function BrushControls({ tool, settings, editingMask = false, maskViewMod
}
function maskModeButtonClass(active: boolean) {
return `rounded-full px-4 py-2 text-base font-medium transition ${active ? "bg-white text-black" : "bg-white/10 text-white hover:bg-white/15"}`;
return `rounded-md px-3 py-1.5 text-xs font-medium transition ${active ? "bg-sky-300 text-slate-950" : "bg-white/[0.06] text-white/70 hover:bg-white/10"}`;
}

View File

@@ -66,11 +66,11 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C
min={0}
max={255}
value={settings.tolerance}
className="w-36"
className="min-w-7 w-[clamp(1.75rem,5vw,4.5rem)] shrink"
aria-label="Chroma key tolerance"
onValueChange={(tolerance) => dispatch(commandIds.toolSetChromaKeySettings, { tolerance })}
/>
<span className="w-10 text-right text-base text-white">{Math.round(settings.tolerance)}</span>
<span className="w-8 text-right text-xs font-medium text-white/85">{Math.round(settings.tolerance)}</span>
</label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}>
@@ -79,11 +79,11 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C
min={0}
max={255}
value={settings.softness}
className="w-36"
className="min-w-7 w-[clamp(1.75rem,5vw,4.5rem)] shrink"
aria-label="Chroma key edge softness"
onValueChange={(softness) => dispatch(commandIds.toolSetChromaKeySettings, { softness })}
/>
<span className="w-10 text-right text-base text-white">{Math.round(settings.softness)}</span>
<span className="w-8 text-right text-xs font-medium text-white/85">{Math.round(settings.softness)}</span>
</label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}>
@@ -92,11 +92,11 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C
min={0}
max={20}
value={settings.feather}
className="w-32"
className="min-w-7 w-[clamp(1.75rem,5vw,4rem)] shrink"
aria-label="Chroma key mask feather"
onValueChange={(feather) => dispatch(commandIds.toolSetChromaKeySettings, { feather })}
/>
<span className="w-8 text-right text-base text-white">{Math.round(settings.feather)}</span>
<span className="w-7 text-right text-xs font-medium text-white/85">{Math.round(settings.feather)}</span>
</label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}>
@@ -105,11 +105,11 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C
min={-20}
max={20}
value={settings.choke}
className="w-32"
className="min-w-7 w-[clamp(1.75rem,5vw,4rem)] shrink"
aria-label="Chroma key mask choke or expand"
onValueChange={(choke) => dispatch(commandIds.toolSetChromaKeySettings, { choke })}
/>
<span className="w-8 text-right text-base text-white">{Math.round(settings.choke)}</span>
<span className="w-7 text-right text-xs font-medium text-white/85">{Math.round(settings.choke)}</span>
</label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}>
@@ -118,11 +118,11 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C
min={0}
max={20}
value={settings.despeckle}
className="w-32"
className="min-w-7 w-[clamp(1.75rem,5vw,4rem)] shrink"
aria-label="Chroma key mask despeckle"
onValueChange={(despeckle) => dispatch(commandIds.toolSetChromaKeySettings, { despeckle })}
/>
<span className="w-8 text-right text-base text-white">{Math.round(settings.despeckle)}</span>
<span className="w-7 text-right text-xs font-medium text-white/85">{Math.round(settings.despeckle)}</span>
</label>
<BottomControlDivider />
<label className={bottomControlFieldClass()}>
@@ -131,16 +131,16 @@ export function ChromaKeyControls({ document, selection, settings, dispatch }: C
min={0}
max={100}
value={settings.spill}
className="w-36"
className="min-w-7 w-[clamp(1.75rem,5vw,4.5rem)] shrink"
aria-label="Chroma key spill suppression"
onValueChange={(spill) => dispatch(commandIds.toolSetChromaKeySettings, { spill })}
/>
<span className="w-10 text-right text-base text-white">{Math.round(settings.spill)}</span>
<span className="w-8 text-right text-xs font-medium text-white/85">{Math.round(settings.spill)}</span>
</label>
<BottomControlDivider />
<button
type="button"
className="rounded-full bg-white px-5 py-2 text-base font-medium text-black transition hover:bg-white/90 disabled:pointer-events-none disabled:opacity-35"
className="rounded-md bg-sky-300 px-4 py-1.5 text-xs font-semibold text-slate-950 transition hover:bg-sky-200 disabled:pointer-events-none disabled:opacity-35"
disabled={!target}
title={target ? "Create or update a layer mask from the chroma key" : "Select one image or raster layer to create a chroma key mask"}
onClick={() => target && void applyChromaKeyMask(target, settings, dispatch)}

View File

@@ -29,12 +29,12 @@ export function BottomControlColorPicker({ value, onValueChange, ...props }: Bot
<div className="flex items-center gap-3">
<button
type="button"
className="grid size-10 place-items-center rounded-full border border-white/40 bg-transparent text-white transition hover:border-white focus:outline-none focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-white"
className="grid size-8 place-items-center rounded-md border border-white/20 bg-white/[0.03] text-white transition hover:border-white/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50"
aria-label={props["aria-label"]}
title={value}
onClick={() => inputRef.current?.click()}
>
<span className="size-6 rounded-full border border-white/25" style={{ backgroundColor: value }} />
<span className="size-5 rounded border border-white/20" style={{ backgroundColor: value }} />
</button>
<input
ref={inputRef}
@@ -45,7 +45,7 @@ export function BottomControlColorPicker({ value, onValueChange, ...props }: Bot
onChange={(event) => onValueChange(event.target.value)}
/>
<input
className="h-10 w-24 bg-transparent px-2 font-mono text-base uppercase text-white outline-none transition placeholder:text-white/30 focus:text-white"
className="h-8 w-20 bg-transparent px-2 font-mono text-xs uppercase text-white outline-none transition placeholder:text-white/25 focus:text-white"
value={draft}
aria-label={`${props["aria-label"]} hex value`}
spellCheck={false}

View File

@@ -23,11 +23,11 @@ export function GenerateActionControls({ settings, generation, dispatch, workflo
const repair = precondition.ready ? undefined : precondition.repair;
return (
<div className="flex max-w-[calc(100vw-2rem)] flex-wrap items-center justify-center gap-2 px-2">
<div className="flex max-w-[calc(100vw-2rem)] flex-nowrap items-center justify-start gap-2 whitespace-nowrap px-2">
<button
type="button"
disabled={!canGenerate}
className="h-12 rounded-full bg-white px-7 text-base font-semibold !text-black transition hover:bg-white/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/40 disabled:pointer-events-none disabled:opacity-35"
className="h-9 rounded-lg bg-sky-300 px-5 text-xs font-semibold !text-slate-950 transition hover:bg-sky-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50 disabled:pointer-events-none disabled:opacity-35"
title={job?.status === "failed" ? job.error : preconditionMessage ?? "Generate with ComfyUI"}
onClick={() => {
void generate();
@@ -36,17 +36,17 @@ export function GenerateActionControls({ settings, generation, dispatch, workflo
{busy && job?.kind === "generate" ? "Generating..." : "Generate"}
</button>
{preconditionMessage ? (
<span className="flex max-w-80 items-center gap-2 rounded-full bg-amber-300/10 px-3 py-2 text-xs text-amber-100/75" role="status">
<span className="flex max-w-80 items-center gap-2 rounded-md border border-amber-300/10 bg-amber-300/[0.07] px-2.5 py-1.5 text-xs text-amber-100/70" role="status">
<span>{preconditionMessage}</span>
{repair === "add-mask" ? (
<button type="button" className="shrink-0 rounded-full bg-white px-3 py-1 font-semibold text-black" onClick={() => void workflow.prepareInpaintMask()}>Add mask</button>
<button type="button" className="shrink-0 rounded-md bg-white px-2.5 py-1 font-semibold text-black" onClick={() => void workflow.prepareInpaintMask()}>Add mask</button>
) : repair === "set-outpaint-padding" ? (
<button type="button" className="shrink-0 rounded-full bg-white px-3 py-1 font-semibold text-black" onClick={() => dispatch(commandIds.toolSetGenerateSettings, { outpaint: { ...settings.outpaint, left: 128, right: 128 } })}>Add padding</button>
<button type="button" className="shrink-0 rounded-md bg-white px-2.5 py-1 font-semibold text-black" onClick={() => dispatch(commandIds.toolSetGenerateSettings, { outpaint: { ...settings.outpaint, left: 128, right: 128 } })}>Add padding</button>
) : null}
</span>
) : null}
<GenerationJobStatus generation={generation} />
{busy ? <button type="button" className="h-9 rounded-full bg-white/5 px-3 text-xs font-semibold text-white/70 hover:bg-white/10 hover:text-white" onClick={workflow.cancel}>Cancel</button> : null}
{busy ? <button type="button" className="h-8 rounded-md bg-white/[0.05] px-2.5 text-xs font-semibold text-white/65 hover:bg-white/[0.09] hover:text-white" onClick={workflow.cancel}>Cancel</button> : null}
{candidate ? (
<>
<CandidatePicker generation={generation} dispatch={dispatch} />
@@ -66,19 +66,19 @@ export function GenerateActionControls({ settings, generation, dispatch, workflo
function CandidatePicker({ generation, dispatch }: { generation: GenerationState; dispatch: AppStore["dispatch"] }) {
return (
<div className="subtle-scrollbar flex max-w-[min(34rem,calc(100vw-2rem))] items-stretch gap-2 overflow-x-auto rounded-[1.25rem] bg-black/20 p-2 ring-1 ring-white/[0.08]" role="group" aria-label="Provisional generation candidates">
<div className="subtle-scrollbar flex max-w-[min(34rem,calc(100vw-2rem))] items-stretch gap-1.5 overflow-x-auto border-l border-white/[0.08] pl-2" role="group" aria-label="Provisional generation candidates">
{generation.candidates.map((candidate) => {
const selected = candidate.id === (generation.selectedCandidateId ?? generation.candidates[0]?.id);
return (
<button
key={candidate.id}
type="button"
className={`relative h-16 w-16 shrink-0 overflow-hidden rounded-xl ring-2 transition ${selected ? "ring-white" : "ring-white/10 hover:ring-white/40"}`}
className={`relative h-16 w-16 shrink-0 overflow-hidden rounded-lg border transition ${selected ? "border-sky-300 shadow-[0_0_0_1px_rgba(125,211,252,0.35)]" : "border-white/10 hover:border-white/30"}`}
title={`Provisional candidate · seed ${candidate.seed}`}
onClick={() => dispatch(commandIds.generationSelectCandidate, { candidateId: candidate.id })}
>
<img src={candidate.source} alt="" className="h-full w-full object-cover" />
{selected ? <span className="absolute inset-x-1 bottom-1 rounded-full bg-black/70 px-1 py-0.5 text-[0.6rem] font-semibold text-white">Reviewing</span> : null}
{selected ? <span className="absolute inset-x-1 bottom-1 rounded bg-black/75 px-1 py-0.5 text-[0.58rem] font-semibold uppercase tracking-wide text-sky-100">Reviewing</span> : null}
</button>
);
})}
@@ -116,7 +116,7 @@ function CandidateControls({
const disabled = busy;
return (
<div className="flex flex-wrap items-center justify-center gap-1 rounded-[1.25rem] bg-white/[0.04] px-2 py-2 ring-1 ring-white/[0.05]">
<div className="flex flex-nowrap items-center justify-center gap-1 border-l border-white/[0.08] pl-2">
<CandidatePreview candidate={candidate} />
<span className="px-2 text-xs font-medium text-white/55" title={`${candidate.settings.model} · ${candidate.mode}`}><strong className="block font-semibold text-white/75">Provisional result</strong>Seed {candidate.seed}</span>
<CandidateCompareControls compareMode={compareMode} disabled={disabled} dispatch={dispatch} />
@@ -170,14 +170,14 @@ function CandidateControls({
function CandidateCompareControls({ compareMode, disabled, dispatch }: { compareMode: GenerationCompareMode; disabled: boolean; dispatch: AppStore["dispatch"] }) {
return (
<span className="flex items-center gap-1 rounded-full bg-black/20 p-1" aria-label="Compare candidate">
<span className="flex items-center gap-0.5 border-l border-white/[0.08] pl-1" aria-label="Compare candidate">
{generationCompareOptions.map((option) => {
const active = compareMode === option.mode;
return (
<button
key={option.mode}
type="button"
className={`h-7 rounded-full px-2 text-[0.7rem] font-semibold transition ${
className={`h-7 rounded px-2 text-[0.68rem] font-semibold transition ${
active ? "bg-white text-black" : "text-white/55 hover:bg-white/10 hover:text-white"
} disabled:pointer-events-none disabled:opacity-35`}
disabled={disabled}
@@ -200,14 +200,14 @@ const generationCompareOptions: Array<{ mode: GenerationCompareMode; label: stri
function CandidatePreview({ candidate }: { candidate: GenerationCandidate }) {
if (!candidate.inputImage) {
return <img src={candidate.source} alt="" className="h-10 w-10 rounded-full bg-black/25 object-cover ring-1 ring-white/10" />;
return <img src={candidate.source} alt="" className="h-10 w-10 rounded-md bg-black/25 object-cover ring-1 ring-white/10" />;
}
return (
<span className="flex items-center -space-x-2" title={candidate.maskImage ? "Input crop, normalized mask, generated candidate" : "Before and generated candidate"}>
<img src={candidate.inputImage} alt="" className="h-10 w-10 rounded-full bg-black/25 object-cover ring-1 ring-white/10" />
{candidate.maskImage ? <img src={candidate.maskImage} alt="" className="h-10 w-10 rounded-full bg-black/25 object-cover ring-1 ring-white/20" /> : null}
<img src={candidate.source} alt="" className="h-10 w-10 rounded-full bg-black/25 object-cover ring-2 ring-white/40" />
<img src={candidate.inputImage} alt="" className="h-10 w-10 rounded-md bg-black/25 object-cover ring-1 ring-white/10" />
{candidate.maskImage ? <img src={candidate.maskImage} alt="" className="h-10 w-10 rounded-md bg-black/25 object-cover ring-1 ring-white/20" /> : null}
<img src={candidate.source} alt="" className="h-10 w-10 rounded-md bg-black/25 object-cover ring-1 ring-sky-300/40" />
</span>
);
}
@@ -216,7 +216,7 @@ function CandidateButton({ label, title, disabled, busy, onClick }: { label: str
return (
<button
type="button"
className="h-9 rounded-full bg-white/5 px-3 text-xs font-semibold text-white/70 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35"
className="h-8 rounded-md bg-white/[0.05] px-2.5 text-xs font-semibold text-white/65 transition hover:bg-white/[0.09] hover:text-white disabled:pointer-events-none disabled:opacity-35"
disabled={disabled}
title={title}
onClick={onClick}

View File

@@ -72,11 +72,11 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
return (
<div className="grid gap-5 pb-2 tabular-nums">
{resources.error ? <p className="rounded-full bg-red-500/10 px-3 py-2 text-xs text-red-200">{resources.error}</p> : null}
{resources.error ? <p className="rounded-md border border-red-400/15 bg-red-500/10 px-2.5 py-2 text-xs text-red-200">{resources.error}</p> : null}
<section className={panelSectionClass()}>
<div className="px-1">
<h2 className="text-base font-semibold text-white/90">Edit with AI</h2>
<h2 className="text-sm font-semibold text-white/90">Edit with AI</h2>
<p className="mt-1 text-xs leading-5 text-white/45">Choose an outcome. Image Studio configures the matching generation workflow.</p>
</div>
<div className="grid grid-cols-2 gap-2">
@@ -85,14 +85,14 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
key={intent.value}
type="button"
aria-pressed={settings.mode === intent.mode}
className={`rounded-[1.25rem] px-3 py-3 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 ${settings.mode === intent.mode ? "bg-white text-black" : "bg-white/[0.05] text-white/75 hover:bg-white/10"}`}
className={`border-l-2 px-2.5 py-1.5 text-left transition focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50 ${settings.mode === intent.mode ? "border-sky-300 bg-sky-300/10 text-sky-100" : "border-transparent text-white/60 hover:border-white/20 hover:text-white"}`}
onClick={() => dispatch(commandIds.toolChooseGenerateIntent, { intent: intent.value })}
>
<span className="block text-sm font-semibold">{intent.label}</span>
<span className={`mt-1 block text-xs leading-4 ${settings.mode === intent.mode ? "text-black/55" : "text-white/40"}`}>{intent.description}</span>
<span className={`mt-1 block text-xs leading-4 ${settings.mode === intent.mode ? "text-sky-100/70" : "text-white/40"}`}>{intent.description}</span>
</button>
))}
<button type="button" disabled className="rounded-[1.25rem] bg-white/[0.025] px-3 py-3 text-left text-white/30" title="Dedicated object removal is not supported by the current backend. Use Replace and describe the desired background.">
<button type="button" disabled className="border-l-2 border-transparent px-2.5 py-1.5 text-left text-white/25" title="Dedicated object removal is not supported by the current backend. Use Replace and describe the desired background.">
<span className="block text-sm font-semibold">Remove</span>
<span className="mt-1 block text-xs leading-4">Use Replace for now; describe what should fill the area.</span>
</button>
@@ -154,7 +154,7 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
<PanelSelect label="VAE" value={settings.vae} options={supportOptions.vaes} ariaLabel="Generate VAE" onValueChange={(vae) => dispatch(commandIds.toolSetGenerateSettings, { vae })} />
</>
) : null}
<PanelNumber label="Seed" aria-label="Generate seed" min={-1} max={Number.MAX_SAFE_INTEGER} value={settings.seed} onValueChange={(seed) => dispatch(commandIds.toolSetGenerateSettings, { seed })} />
<PanelNumber label="Seed" aria-label="Generate seed" value={settings.seed} onValueChange={(seed) => dispatch(commandIds.toolSetGenerateSettings, { seed })} />
<PanelSelect label="Sampler" value={settings.sampler} options={samplerOptions} ariaLabel="Generate sampler" onValueChange={(sampler) => dispatch(commandIds.toolSetGenerateSettings, { sampler })} />
<PanelSelect label="Scheduler" value={settings.scheduler} options={schedulerOptions} ariaLabel="Generate scheduler" onValueChange={(scheduler) => dispatch(commandIds.toolSetGenerateSettings, { scheduler })} />
<div className="grid grid-cols-2 gap-2">
@@ -211,12 +211,12 @@ export function GenerateControls({ settings, resources, dispatch }: GenerateCont
<span className="min-w-0 flex-1 text-right text-white">{settings.inpaint.maskedAreaOnly ? "Mask crop" : "Full layer"}</span>
</button>
<div className="grid grid-cols-2 gap-2">
<PanelNumber label="Pad" aria-label="Inpaint crop padding" value={settings.inpaint.cropPadding} max={2048} onValueChange={(cropPadding) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, cropPadding } })} />
<PanelNumber label="Grow" aria-label="Inpaint backend grow mask" value={settings.inpaint.growMaskBy} max={256} onValueChange={(growMaskBy) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, growMaskBy } })} />
<PanelNumber label="Expand" aria-label="Inpaint mask expand" value={settings.inpaint.maskExpand} min={-256} max={256} onValueChange={(maskExpand) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskExpand } })} />
<PanelNumber label="Feather" aria-label="Inpaint mask feather" value={settings.inpaint.maskFeather} max={256} onValueChange={(maskFeather) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskFeather } })} />
<PanelNumber label="Blur" aria-label="Inpaint mask blur" value={settings.inpaint.maskBlur} max={256} onValueChange={(maskBlur) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskBlur } })} />
<PanelNumber label="Clean" aria-label="Inpaint mask despeckle" value={settings.inpaint.maskDespeckle} max={64} onValueChange={(maskDespeckle) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskDespeckle } })} />
<PanelNumber label="Pad" aria-label="Inpaint crop padding" value={settings.inpaint.cropPadding} onValueChange={(cropPadding) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, cropPadding } })} />
<PanelNumber label="Grow" aria-label="Inpaint backend grow mask" value={settings.inpaint.growMaskBy} onValueChange={(growMaskBy) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, growMaskBy } })} />
<PanelNumber label="Expand" aria-label="Inpaint mask expand" value={settings.inpaint.maskExpand} onValueChange={(maskExpand) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskExpand } })} />
<PanelNumber label="Feather" aria-label="Inpaint mask feather" value={settings.inpaint.maskFeather} onValueChange={(maskFeather) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskFeather } })} />
<PanelNumber label="Blur" aria-label="Inpaint mask blur" value={settings.inpaint.maskBlur} onValueChange={(maskBlur) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskBlur } })} />
<PanelNumber label="Clean" aria-label="Inpaint mask despeckle" value={settings.inpaint.maskDespeckle} onValueChange={(maskDespeckle) => dispatch(commandIds.toolSetGenerateSettings, { inpaint: { ...settings.inpaint, maskDespeckle } })} />
</div>
</div>
</section> : null}
@@ -236,26 +236,26 @@ function SectionTitle({ title }: { title: string }) {
}
function panelSectionClass() {
return "grid gap-3 rounded-[1.75rem] bg-white/[0.035] p-3 ring-1 ring-white/[0.04]";
return "grid gap-2 border-b border-white/[0.07] px-0.5 pb-3 last:border-b-0 last:pb-0";
}
function sectionToggleClass() {
return "flex w-full items-center justify-between gap-3 rounded-[1.25rem] px-2 py-1 text-left transition hover:bg-white/[0.04] focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30";
return "flex w-full items-center justify-between gap-2 rounded-md px-1 py-0.5 text-left transition hover:bg-white/[0.04] focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50";
}
function PanelSelect<TValue extends string>({ label, value, options, ariaLabel, onValueChange }: { label: string; value: TValue; options: readonly BottomControlSelectOption<TValue>[]; ariaLabel: string; onValueChange: (value: TValue) => void }) {
return (
<div className="rounded-[1.25rem] bg-white/[0.04] p-1">
<div className="p-0.5">
<BottomControlSelectMenu label={label} value={value} options={options} aria-label={ariaLabel} placement="inline" onValueChange={onValueChange} />
</div>
);
}
function PanelNumber({ label, value, onValueChange, min = 0, max = 4096, ...props }: { label: string; "aria-label": string; value: number; min?: number; max?: number; onValueChange: (value: number) => void }) {
function PanelNumber({ label, value, onValueChange, ...props }: { label: string; "aria-label": string; value: number; onValueChange: (value: number) => void }) {
return (
<label className={panelRowClass()}>
<span className={panelLabelClass()}>{label}</span>
<NumberInput {...props} min={min} max={max} value={value} onValueChange={onValueChange} />
<NumberInput {...props} value={value} onValueChange={onValueChange} />
</label>
);
}
@@ -269,14 +269,14 @@ function SizeControl({ refRoot, open, setOpen, settings, dispatch }: { refRoot:
<CaretDown size={16} weight="bold" />
</button>
{open ? (
<div className="absolute right-0 top-full z-30 mt-2 grid w-full gap-3 rounded-[1.5rem] bg-slate-950/90 p-3 text-white shadow-2xl ring-1 ring-white/10 backdrop-blur-xl">
<div className="app-surface absolute right-0 top-full z-30 mt-1.5 grid w-full gap-2 rounded-lg p-2.5 text-white shadow-2xl">
<div className="grid grid-cols-2 gap-2">
<PanelNumber label="W" aria-label="Generate width" value={settings.width} onValueChange={(width) => dispatch(commandIds.toolSetGenerateSettings, { width })} />
<PanelNumber label="H" aria-label="Generate height" value={settings.height} onValueChange={(height) => dispatch(commandIds.toolSetGenerateSettings, { height })} />
</div>
<div className="grid grid-cols-5 gap-2">
{sizePresets.map((preset) => (
<button key={preset.label} type="button" className="rounded-full bg-white/5 px-2 py-2 text-xs text-white/75 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30" onClick={() => dispatch(commandIds.toolSetGenerateSettings, { width: preset.w, height: preset.h })}>
<button key={preset.label} type="button" className="rounded-md bg-white/[0.04] px-2 py-1.5 text-xs text-white/65 transition hover:bg-white/[0.08] hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50" onClick={() => dispatch(commandIds.toolSetGenerateSettings, { width: preset.w, height: preset.h })}>
{preset.label}
</button>
))}
@@ -287,12 +287,19 @@ function SizeControl({ refRoot, open, setOpen, settings, dispatch }: { refRoot:
);
}
function NumberInput({ value, onValueChange, min = 0, max = 4096, ...props }: { "aria-label": string; value: number; min?: number; max?: number; onValueChange: (value: number) => void }) {
return <input {...props} type="number" min={min} max={max} step={1} value={Math.round(value)} className="h-9 w-16 rounded-full bg-white/5 px-2 text-right text-sm text-white outline-none transition hover:bg-white/10 focus:bg-white/10 focus:ring-2 focus:ring-white/30" onChange={(event) => onValueChange(Number(event.currentTarget.value))} />;
function NumberInput({ value, onValueChange, ...props }: { "aria-label": string; value: number; onValueChange: (value: number) => void }) {
const [draft, setDraft] = useState(String(Math.round(value)));
useEffect(() => setDraft(String(Math.round(value))), [value]);
const commit = () => {
const next = Number(draft.trim());
if (draft.trim() !== "" && Number.isFinite(next)) onValueChange(next);
else setDraft(String(Math.round(value)));
};
return <input {...props} type="text" inputMode="numeric" value={draft} className="h-7 w-12 rounded bg-transparent px-1.5 text-right font-mono text-xs text-white/90 outline-none transition hover:bg-white/[0.04] focus:bg-white/[0.06] focus:ring-1 focus:ring-sky-300/50" onFocus={(event) => event.currentTarget.select()} onChange={(event) => setDraft(event.currentTarget.value)} onBlur={commit} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter") event.currentTarget.blur(); if (event.key === "Escape") { setDraft(String(Math.round(value))); event.currentTarget.blur(); } }} />;
}
function panelRowClass() {
return "flex min-h-11 items-center justify-between gap-3 rounded-full bg-white/[0.04] px-4";
return "flex min-h-9 items-center justify-between gap-2 border-b border-white/[0.045] px-1 last:border-b-0";
}
function compactRowButtonClass() {
@@ -304,5 +311,5 @@ function panelLabelClass() {
}
function panelTextAreaClass(extra = "") {
return `${extra} resize-none rounded-[1.25rem] bg-white/5 px-4 py-3 text-sm text-white outline-none transition placeholder:text-white/25 focus:bg-white/[0.07] focus:ring-2 focus:ring-white/30`;
return `${extra} resize-none rounded-lg border border-white/[0.05] bg-white/[0.035] px-3 py-2 text-xs text-white outline-none transition placeholder:text-white/25 focus:bg-white/[0.06] focus:ring-1 focus:ring-sky-300/50`;
}

View File

@@ -19,16 +19,19 @@ export function MagicWandControls({ settings, dispatch }: { settings: MagicWandS
<BottomControlDivider />
<Slider label="Clean" value={settings.despeckle} min={0} max={20} onChange={(despeckle) => dispatch(commandIds.toolSetMagicWandSettings, { despeckle })} />
<BottomControlDivider />
<button type="button" className={`rounded-full px-4 py-2 text-base font-medium transition ${settings.contiguous ? "bg-white text-black" : "bg-white/10 text-white"}`} aria-label="Contiguous selection" aria-pressed={settings.contiguous} title="Select only connected pixels" onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { contiguous: !settings.contiguous })}>Contiguous</button>
<button type="button" className={`rounded-md px-3 py-1.5 text-xs font-medium transition ${settings.contiguous ? "bg-sky-300 text-slate-950" : "bg-white/[0.06] text-white/70"}`} aria-label="Contiguous selection" aria-pressed={settings.contiguous} title="Select only connected pixels" onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { contiguous: !settings.contiguous })}>Contiguous</button>
<BottomControlDivider />
{(["replace", "add", "subtract"] as const).map((mode) => (
<button key={mode} type="button" className={`rounded-full px-4 py-2 text-base font-medium capitalize transition ${settings.mode === mode ? "bg-white text-black" : "bg-white/10 text-white"}`} aria-pressed={settings.mode === mode} onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { mode })}>{mode}</button>
<button key={mode} type="button" className={`rounded-md px-3 py-1.5 text-xs font-medium capitalize transition ${settings.mode === mode ? "bg-sky-300 text-slate-950" : "bg-white/[0.06] text-white/70"}`} aria-pressed={settings.mode === mode} onClick={() => dispatch(commandIds.toolSetMagicWandSettings, { mode })}>{mode}</button>
))}
<span className="px-2 text-sm text-white/60">Shift-click adds, Alt-click subtracts</span>
<span className="flex items-center gap-1 px-1 text-[0.65rem] text-white/45" aria-label="Shift click adds; Alt click subtracts" title="Shift-click adds · Alt-click subtracts">
<kbd className="rounded border border-white/10 px-1 py-0.5 font-sans text-white/65"> +</kbd>
<kbd className="rounded border border-white/10 px-1 py-0.5 font-sans text-white/65"> </kbd>
</span>
</div>
);
}
function Slider({ label, value, min, max, onChange }: { label: string; value: number; min: number; max: number; onChange: (value: number) => void }) {
return <label className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>{label}</span><BottomControlSlider min={min} max={max} value={value} className="w-32" aria-label={`Magic wand ${label}`} onValueChange={onChange} /><span className="w-8 text-right text-base text-white">{Math.round(value)}</span></label>;
return <label className={`${bottomControlFieldClass()} min-w-0`}><span className={bottomControlLabelClass()}>{label}</span><BottomControlSlider min={min} max={max} value={value} className="min-w-8 w-[clamp(2rem,7vw,5rem)] shrink" aria-label={`Magic wand ${label}`} onValueChange={onChange} /><span className="w-7 shrink-0 text-right text-xs font-medium text-white/85">{Math.round(value)}</span></label>;
}

View File

@@ -90,7 +90,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
<button
ref={buttonRef}
type="button"
className={`inline-flex h-10 min-w-32 max-w-full items-center rounded-full text-base text-white/85 transition hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white/30 ${placement === "inline" ? "w-full px-4" : ""}`}
className={`inline-flex h-8 min-w-28 max-w-full items-center rounded-md text-xs text-white/75 transition hover:bg-white/[0.07] hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/50 ${placement === "inline" ? "w-full px-2.5" : ""}`}
aria-label={props["aria-label"]}
aria-haspopup="listbox"
aria-expanded={open}
@@ -112,7 +112,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
value={value}
onValueChange={onValueChange}
setOpen={setOpen}
className="subtle-scrollbar mt-2 max-h-56 w-full overflow-auto rounded-[1.25rem] bg-white/[0.04] p-1 text-white ring-1 ring-white/10"
className="subtle-scrollbar mt-1.5 max-h-56 w-full overflow-auto border-t border-white/[0.07] pt-1 text-white"
aria-label={props["aria-label"]}
/>
) : open ? (
@@ -124,7 +124,7 @@ export function BottomControlSelectMenu<TValue extends string>({ value, options,
value={value}
onValueChange={onValueChange}
setOpen={setOpen}
className="subtle-scrollbar z-50 overflow-auto rounded-[1.5rem] bg-slate-950/90 p-1 text-white shadow-2xl ring-1 ring-white/10 backdrop-blur-xl"
className="app-surface subtle-scrollbar z-50 overflow-auto rounded-lg p-1 text-white shadow-2xl"
style={menuStyle}
aria-label={props["aria-label"]}
/>,
@@ -174,7 +174,7 @@ function SelectOptions<TValue extends string>({ menuRef, id, options, value, cla
<button
key={option.value}
type="button"
className={`flex h-10 w-full items-center gap-3 rounded-full px-3 text-left text-sm transition ${selected ? "bg-white !text-black" : "text-white/80 hover:bg-white/10 hover:text-white"}`}
className={`flex h-8 w-full items-center gap-2 rounded-md px-2.5 text-left text-xs transition ${selected ? "bg-sky-300 !text-slate-950" : "text-white/70 hover:bg-white/[0.07] hover:text-white"}`}
role="option"
aria-selected={selected}
tabIndex={selected ? 0 : -1}

View File

@@ -6,6 +6,7 @@ export type BottomControlSliderProps = {
max: number;
value: number;
step?: number;
disabled?: boolean;
className?: string;
onValueChange: (value: number) => void;
};

View File

@@ -138,14 +138,14 @@ export function TransformControls({ bounds, target, documentIndex, layerInfo, di
type CropDraft = Record<BoundsField, string>;
function CropEditor({ draft, setDraft, onApply, onCancel, onReset }: { draft: CropDraft; setDraft: Dispatch<SetStateAction<CropDraft>>; onApply: () => void; onCancel: () => void; onReset: () => void }) {
return <div className="flex items-center gap-2 rounded-2xl bg-black/25 px-3 py-2" aria-label="Crop source pixels">
return <div className="flex items-center gap-2 border-l border-white/[0.08] pl-3" aria-label="Crop source pixels">
{(["x", "y", "w", "h"] as const).map((field) => <label key={field} className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>{field.toUpperCase()}</span><input className={bottomControlInputClass()} inputMode="decimal" value={draft[field]} aria-label={`Crop ${field}`} onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))} onKeyDown={(event) => event.stopPropagation()} /></label>)}
<button type="button" className={actionButtonClass()} onClick={onApply}>Apply</button><button type="button" className={actionButtonClass()} onClick={onCancel}>Cancel</button><button type="button" className={actionButtonClass()} onClick={onReset}>Reset</button>
</div>;
}
function ArtboardResizeEditor({ draft, setDraft, onApply, onCancel }: { draft: { w: string; h: string; scaleContents: boolean }; setDraft: Dispatch<SetStateAction<{ w: string; h: string; scaleContents: boolean }>>; onApply: () => void; onCancel: () => void }) {
return <div className="flex items-center gap-2 rounded-2xl bg-black/25 px-3 py-2" aria-label="Resize artboard canvas">
return <div className="flex items-center gap-2 border-l border-white/[0.08] pl-3" aria-label="Resize artboard canvas">
{(["w", "h"] as const).map((field) => <label key={field} className={bottomControlFieldClass()}><span className={bottomControlLabelClass()}>{field.toUpperCase()}</span><input className={bottomControlInputClass()} inputMode="decimal" value={draft[field]} aria-label={`Canvas ${field}`} onChange={(event) => setDraft((current) => ({ ...current, [field]: event.target.value }))} onKeyDown={(event) => event.stopPropagation()} /></label>)}
<label className="flex items-center gap-2 text-xs text-white/70"><input type="checkbox" checked={draft.scaleContents} onChange={(event) => setDraft((current) => ({ ...current, scaleContents: event.target.checked }))} />Scale contents</label>
<span className="max-w-40 text-[10px] text-white/45">Off changes canvas bounds only.</span>
@@ -182,7 +182,7 @@ function rotationDegrees(layerInfo?: IndexedLayerInfo) {
}
function actionButtonClass() {
return "inline-flex h-10 items-center gap-2 rounded-full px-3 text-xs font-semibold text-white/70 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35";
return "inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-semibold text-white/65 transition hover:bg-white/[0.07] hover:text-white disabled:pointer-events-none disabled:opacity-35";
}
function BoundsInput({

View File

@@ -1,9 +1,9 @@
export function bottomControlMenuClass() {
return "flex w-full items-center justify-center gap-4 tabular-nums";
return "flex max-w-full min-w-0 flex-nowrap items-center justify-center gap-2 whitespace-nowrap tabular-nums";
}
export function bottomControlFieldClass() {
return "flex items-center gap-3";
return "flex min-w-0 shrink items-center gap-2 whitespace-nowrap";
}
export function bottomControlDividerClass() {
@@ -11,19 +11,19 @@ export function bottomControlDividerClass() {
}
export function bottomControlLabelClass() {
return "text-sm text-white/60";
return "shrink-0 text-xs text-white/60";
}
export function bottomControlValueClass() {
return "min-w-16 text-center text-base text-white";
return "min-w-12 text-center text-xs font-medium text-white/85";
}
export function bottomControlInputClass() {
return "h-10 w-20 px-2 text-center text-base text-white outline-none transition placeholder:text-white/30 focus:text-white";
return "h-7 w-12 rounded bg-transparent px-1.5 text-right font-mono text-xs text-white/90 outline-none transition placeholder:text-white/25 hover:bg-white/[0.04] focus:bg-white/[0.06] focus:ring-1 focus:ring-sky-300/50";
}
export function bottomControlIconSlotClass() {
return "grid size-12 place-items-center rounded-full text-white/80";
return "grid size-8 place-items-center rounded-lg text-white/70";
}
export function bottomControlButtonClass() {

View File

@@ -0,0 +1,12 @@
import { describe, expect, test } from "bun:test";
import { canvasCursorClass } from "./cursor";
describe("canvas cursor", () => {
test("keeps the brush cursor while non-operation UI is open", () => {
expect(canvasCursorClass({ type: "tool", tool: "brush" }, false, false, true, false)).toBe("cursor-crosshair");
});
test("uses the default cursor while an operation suspends canvas tools", () => {
expect(canvasCursorClass({ type: "tool", tool: "brush" }, false, false, true, true)).toBe("cursor-default");
});
});

View File

@@ -1,6 +1,7 @@
import type { InteractionMode } from "@editor/tools";
import { isPanInteractionMode } from "@editor/tools";
export function canvasCursorClass(interactionMode: InteractionMode, isPanning: boolean, hasBrushPreview = false, canBrush = true) {
export function canvasCursorClass(interactionMode: InteractionMode, isPanning: boolean, hasBrushPreview = false, canBrush = true, operationOpen = false) {
if (operationOpen) return "cursor-default";
if (isPanning) return "cursor-grabbing";
if (isPanInteractionMode(interactionMode)) return "cursor-grab";
if (interactionMode.type === "tool" && (interactionMode.tool === "brush" || interactionMode.tool === "eraser")) {

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, type RefObject } from "react";
import { commandIds } from "@commands/ids";
import type { AppStore } from "@editor/store";
import { isOperationWorkspacePanel } from "@editor/state";
import { isPanInteractionMode } from "@editor/tools";
import type { GlobalKeybindConsumer, GlobalPointerConsumer, GlobalWheelConsumer } from "@input/index";
import {
@@ -59,6 +60,10 @@ export function useCanvasInput(
}
const state = store.getState();
if (isOperationWorkspacePanel(state.editor.workspace.panel)) {
clearBrushPreview();
return;
}
if (!canPreviewBrush(state.document, state.editor)) {
clearBrushPreview();
return;
@@ -117,7 +122,7 @@ export function useCanvasInput(
const state = store.getState();
const documentPoint = viewportPointToDocumentPoint(inputEvent.position, state.editor.viewport);
const brush = (inputEvent.buttons & 1) === 1 && !isPanInteractionMode(state.editor.tools.interactionMode)
const brush = !isOperationWorkspacePanel(state.editor.workspace.panel) && (inputEvent.buttons & 1) === 1 && !isPanInteractionMode(state.editor.tools.interactionMode)
? beginBrushSession(state.document, state.editor, documentPoint)
: undefined;
if (brush) {
@@ -129,7 +134,7 @@ export function useCanvasInput(
return;
}
if (state.editor.tools.activeTool === "magicWand") {
if (!isOperationWorkspacePanel(state.editor.workspace.panel) && state.editor.tools.activeTool === "magicWand") {
void applyMagicWandAt(store, documentPoint, inputEvent.shiftKey ? "add" : inputEvent.altKey ? "subtract" : undefined);
event.preventDefault();
return;

View File

@@ -33,54 +33,62 @@
}
@layer components {
.app-surface {
background: rgb(13 17 23 / 0.88);
border: 1px solid rgb(255 255 255 / 0.075);
box-shadow: 0 12px 32px rgb(0 0 0 / 0.28), inset 0 1px rgb(255 255 255 / 0.025);
backdrop-filter: blur(18px) saturate(120%);
}
.bottom-control-slider {
@apply h-8 cursor-pointer appearance-none bg-transparent outline-none;
@apply h-6 cursor-pointer appearance-none bg-transparent outline-none;
}
.bottom-control-slider::-webkit-slider-runnable-track {
height: 0.25rem;
border-radius: 0;
background: linear-gradient(to right, rgb(255 255 255 / 0.9) var(--slider-progress), rgb(255 255 255 / 0.22) var(--slider-progress));
height: 0.1875rem;
border-radius: 9999px;
background: linear-gradient(to right, rgb(125 211 252 / 0.9) var(--slider-progress), rgb(255 255 255 / 0.12) var(--slider-progress));
}
.bottom-control-slider::-webkit-slider-thumb {
width: 1.25rem;
height: 1.25rem;
margin-top: -0.5rem;
width: 0.75rem;
height: 0.75rem;
margin-top: -0.28125rem;
appearance: none;
border: 1px solid rgb(255 255 255 / 0.9);
border: 2px solid rgb(125 211 252);
border-radius: 9999px;
background: rgb(255 255 255);
background: rgb(15 23 42);
box-shadow: 0 0 0 2px rgb(15 23 42 / 0.8);
}
.bottom-control-slider::-moz-range-track {
height: 0.25rem;
border-radius: 0;
background: rgb(255 255 255 / 0.22);
height: 0.1875rem;
border-radius: 9999px;
background: rgb(255 255 255 / 0.12);
}
.bottom-control-slider::-moz-range-progress {
height: 0.25rem;
border-radius: 0;
background: rgb(255 255 255 / 0.9);
height: 0.1875rem;
border-radius: 9999px;
background: rgb(125 211 252 / 0.9);
}
.bottom-control-slider::-moz-range-thumb {
width: 1.25rem;
height: 1.25rem;
border: 1px solid rgb(255 255 255 / 0.9);
width: 0.75rem;
height: 0.75rem;
border: 2px solid rgb(125 211 252);
border-radius: 9999px;
background: rgb(255 255 255);
background: rgb(15 23 42);
}
.bottom-control-slider:focus-visible::-webkit-slider-thumb {
outline: 1px solid rgb(255 255 255);
outline-offset: 2px;
outline: 2px solid rgb(125 211 252 / 0.35);
outline-offset: 3px;
}
.bottom-control-slider:focus-visible::-moz-range-thumb {
outline: 1px solid rgb(255 255 255);
outline-offset: 2px;
outline: 2px solid rgb(125 211 252 / 0.35);
outline-offset: 3px;
}
.subtle-scrollbar {

View File

@@ -20,9 +20,9 @@ export function MaskStatus({ asset }: { asset: Asset }) {
};
}, [asset.source, asset.intrinsicSize.w, asset.intrinsicSize.h]);
if (!analysis) return <span className="rounded-full bg-white/5 px-3 py-1 text-xs text-sky-100/45">Reading</span>;
if (!analysis) return <span className="rounded bg-white/[0.04] px-2 py-0.5 text-[0.68rem] text-sky-100/40">Reading</span>;
return (
<span className="inline-flex min-w-0 items-center gap-2 rounded-full bg-white/5 px-2 py-1 text-xs text-sky-100/65">
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-md bg-white/[0.04] px-1.5 py-1 text-[0.68rem] text-sky-100/60">
<img src={analysis.thumbnail} alt="" className="h-7 w-10 rounded-md bg-black/30 object-cover ring-1 ring-white/10" />
<span className="whitespace-nowrap">Reveal {formatPercent(analysis.coverage)}</span>
<span className="whitespace-nowrap text-sky-100/45">Inpaint {formatPercent(analysis.hiddenCoverage)}</span>
@@ -80,5 +80,5 @@ function MaskOperationButton({
}
function maskActionButtonClass() { return "rounded-full bg-white/5 px-2.5 py-1 text-[0.7rem] font-semibold text-white/60 transition hover:bg-white/10 hover:text-white disabled:pointer-events-none disabled:opacity-35"; }
function maskActionButtonClass() { return "rounded-md bg-white/[0.05] px-2 py-1 text-[0.68rem] font-semibold text-white/55 transition hover:bg-white/[0.09] hover:text-white disabled:pointer-events-none disabled:opacity-35"; }
function formatPercent(value: number) { return `${Math.round(value * 100)}%`; }