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
22 changes: 22 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,28 @@ Only after that translation should the idea appear in product UI.
This is how visual taste becomes a maintainable system instead of a sequence of
one-off page edits.

## 2.1 Density and scale

`mini-stim` should default to a compact work-surface density.

The desired proportion is closer to a browser surface viewed around 75% than to
large default web-app chrome. This is a system scale decision, not a user zoom
workaround.

When density changes, scale these together:

- typography tokens
- spacing tokens
- icon and avatar sizes
- badge and control heights
- shell, panel, composer, rail, and Inspect geometry
- inspection overlay labels

Do not shrink only spacing while leaving type and symbols large. That creates a
compressed but disproportionate surface. Do not shrink only font size while
leaving chrome large either. The target is a coherent ratio: readable text in a
denser tool surface.

## 3. Design pairing workflow

Design work in `mini-stim` should treat the current local product surface as a
Expand Down
248 changes: 182 additions & 66 deletions apps/client/soma/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,30 @@ import {
useSessionTurnTimeline,
} from "@mini-stim/hooks";
import { uiStorage } from "@mini-stim/storage";
import { type PointerEvent as ReactPointerEvent, useEffect, useMemo, useState } from "react";
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useState,
} from "react";

import { STIM_APP_NAMESPACE } from "./appNamespace";
import { ChatShell } from "./components/ChatShell";
import { InspectPanel } from "./components/InspectPanel";
import { NavigationDock } from "./components/NavigationDock";
import { SessionRail } from "./components/SessionRail";
import { type InspectTarget, subscribeInspectTarget } from "./events/inspect";
import {
type InspectEvent,
type InspectTarget,
subscribeInspect,
toggleInspectPanel,
} from "./events/inspect";
import {
type NavigationEvent,
subscribeNavigation,
toggleNavigationPanel,
} from "./events/navigation";
import type { NavigationMode } from "./navigationMode";

const DEFAULT_RAIL_WIDTH = 304;
Expand All @@ -45,7 +61,29 @@ export function App() {
const [error, setError] = useState<string | null>(null);
const [inspecting, setInspecting] = useState(preferences.inspect.open);
const [inspectTarget, setInspectTarget] = useState<InspectTarget | null>(null);
const [navigationMode, setNavigationMode] = useState<NavigationMode>("sessions");
const [navigation, setNavigation] = useState(preferences.navigation);

const commitNavigation = useCallback((updater: (current: NavigationState) => NavigationState) => {
setNavigation((current) => {
const next = updater(current);
uiStorage.update((stored) => ({
...stored,
navigation: next,
}));
return next;
});
}, []);

const commitInspectOpen = useCallback((open: boolean) => {
setInspecting(open);
uiStorage.update((current) => ({
...current,
inspect: {
...current.inspect,
open,
},
}));
}, []);

useEffect(() => {
if (!selectedSessionId && sessions.length) {
Expand All @@ -64,51 +102,51 @@ export function App() {

useEffect(
() =>
subscribeInspectTarget(({ target }) => {
setError(null);
setInspectTarget(target);
setInspecting(true);
if (target.sessionId !== selectedSessionId) {
actions.selectAndGet(target.sessionId);
} else {
actions.refreshRuntime(target.sessionId);
}
uiStorage.update((current) => ({
...current,
inspect: {
...current.inspect,
open: true,
},
}));
subscribeInspect((event) => {
handleInspectEvent(event, {
actions,
commitInspectOpen,
inspecting,
selectedSessionId,
setError,
setInspectTarget,
});
}),
[actions, selectedSessionId],
[actions, commitInspectOpen, inspecting, selectedSessionId],
);

function openInspect() {
setInspectTarget(null);
if (selectedSessionId) {
actions.refreshRuntime(selectedSessionId);
useEffect(
() =>
subscribeNavigation((event) => {
commitNavigation((current) => nextNavigationState(current, event));
}),
[commitNavigation],
);

useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.altKey || event.shiftKey) {
return;
}
if (!event.metaKey && !event.ctrlKey) {
return;
}

switch (event.key.toLowerCase()) {
case "b":
event.preventDefault();
toggleNavigationPanel();
return;
case "i":
event.preventDefault();
toggleInspectPanel();
return;
}
}
setInspecting(true);
uiStorage.update((current) => ({
...current,
inspect: {
...current.inspect,
open: true,
},
}));
}

function closeInspect() {
setInspecting(false);
uiStorage.update((current) => ({
...current,
inspect: {
...current.inspect,
open: false,
},
}));
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);

const busy = pending > 0;
const debouncedBusy = useDebouncedValue(busy, { debounceMs: 150 });
Expand Down Expand Up @@ -255,36 +293,38 @@ export function App() {
}

return (
<AppRoot inspection={{ namespace: STIM_APP_NAMESPACE, options: { labels: true } }}>
<AppRoot inspection={{ namespace: STIM_APP_NAMESPACE, options: { labels: false } }}>
<DockShell.Root>
<DockShell.Dock>
<NavigationDock mode={navigationMode} onModeChange={setNavigationMode} />
<NavigationDock mode={navigation.mode} open={navigation.open} />
</DockShell.Dock>
<DockShell.Grid>
<Grid
template={inspecting ? "sidebar-main-inspect" : "sidebar-main"}
template={gridTemplate(navigation.open, inspecting)}
gap="shell"
grow
sidebarWidthPx={layout.railWidthPx}
sidebarWidthPx={navigation.open ? layout.railWidthPx : null}
inspectWidthPx={layout.inspectWidthPx}
>
<GridItem area="sidebar" tag="aside">
<SessionRail
mode={navigationMode}
onCreate={createNewSession}
onSelect={selectSession}
previews={previews}
selectedSessionId={selectedSessionId}
sessions={sessions}
soulIdentity={soulIdentity}
/>
<ResizeHandle
aria-label="Resize sessions panel"
aria-pressed={resizing === "rail"}
placement="end"
onPointerDown={beginRailResize}
/>
</GridItem>
{navigation.open ? (
<GridItem area="sidebar" tag="aside">
<SessionRail
mode={navigation.mode}
onCreate={createNewSession}
onSelect={selectSession}
previews={previews}
selectedSessionId={selectedSessionId}
sessions={sessions}
soulIdentity={soulIdentity}
/>
<ResizeHandle
aria-label="Resize sessions panel"
aria-pressed={resizing === "rail"}
placement="end"
onPointerDown={beginRailResize}
/>
</GridItem>
) : null}
<GridItem area="main" tag="main">
<ChatShell
activity={activity}
Expand All @@ -293,7 +333,6 @@ export function App() {
error={visibleError}
inspecting={inspecting}
onDraftChange={setDraft}
onOpenInspect={openInspect}
onSend={send}
onTitleCommit={updateTitle}
selectedSessionId={selectedSessionId}
Expand All @@ -307,7 +346,6 @@ export function App() {
{inspecting ? (
<GridItem area="inspect" tag="aside">
<InspectPanel
onClose={closeInspect}
runtime={runtime}
target={inspectTarget?.sessionId === selectedSessionId ? inspectTarget : null}
/>
Expand All @@ -334,3 +372,81 @@ function clampWidth(width: number, target: "inspect" | "rail") {
const range = target === "rail" ? RAIL_WIDTH_RANGE : INSPECT_WIDTH_RANGE;
return Math.round(Math.min(range.max, Math.max(range.min, width)));
}

type NavigationState = {
mode: NavigationMode;
open: boolean;
};

function nextNavigationState(current: NavigationState, event: NavigationEvent) {
switch (event.type) {
case "navigation:mode-selected":
if (current.mode === event.mode) {
return {
...current,
open: !current.open,
};
}
return {
mode: event.mode,
open: true,
};
case "navigation:panel-toggled":
return {
...current,
open: !current.open,
};
}
}

function handleInspectEvent(
event: InspectEvent,
context: {
actions: ReturnType<typeof useSessionActions>;
commitInspectOpen: (open: boolean) => void;
inspecting: boolean;
selectedSessionId: string | null;
setError: (value: string | null) => void;
setInspectTarget: (value: InspectTarget | null) => void;
},
) {
switch (event.type) {
case "inspect:target:selected":
context.setError(null);
context.setInspectTarget(event.target);
context.commitInspectOpen(true);
if (event.target.sessionId !== context.selectedSessionId) {
context.actions.selectAndGet(event.target.sessionId);
} else {
context.actions.refreshRuntime(event.target.sessionId);
}
return;
case "inspect:panel:opened":
context.setError(null);
context.commitInspectOpen(true);
if (context.selectedSessionId) {
context.actions.refreshRuntime(context.selectedSessionId);
}
return;
case "inspect:panel:closed":
context.commitInspectOpen(false);
return;
case "inspect:panel:toggled": {
const nextOpen = !context.inspecting;
context.commitInspectOpen(nextOpen);
if (nextOpen && context.selectedSessionId) {
context.actions.refreshRuntime(context.selectedSessionId);
}
}
}
}

function gridTemplate(navigationOpen: boolean, inspecting: boolean) {
if (navigationOpen && inspecting) {
return "sidebar-main-inspect";
}
if (navigationOpen) {
return "sidebar-main";
}
return inspecting ? "main-inspect" : "main";
}
4 changes: 2 additions & 2 deletions apps/client/soma/web/src/components/ChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,13 @@ import {
import { useEffect, useState } from "react";

import { STIM_APP_NAMESPACE } from "../appNamespace";
import { openInspectPanel } from "../events/inspect";

export function ChatHeader(props: {
activity: string;
busy: boolean;
connection: string;
inspecting: boolean;
onOpenInspect: () => void;
onTitleCommit: (title: string | null) => void;
selectedSessionId: string | null;
title: string;
Expand Down Expand Up @@ -113,7 +113,7 @@ export function ChatHeader(props: {
variant="ghost"
disabled={!props.selectedSessionId}
type="button"
onClick={props.onOpenInspect}
onClick={openInspectPanel}
>
Inspect
</Button>
Expand Down
2 changes: 0 additions & 2 deletions apps/client/soma/web/src/components/ChatShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export function ChatShell(props: {
error: string | null;
inspecting: boolean;
onDraftChange: (value: string) => void;
onOpenInspect: () => void;
onSend: () => void;
onTitleCommit: (title: string | null) => void;
selectedSessionId: string | null;
Expand Down Expand Up @@ -40,7 +39,6 @@ export function ChatShell(props: {
busy={props.busy}
connection={props.connection}
inspecting={props.inspecting}
onOpenInspect={props.onOpenInspect}
onTitleCommit={props.onTitleCommit}
selectedSessionId={props.selectedSessionId}
title={props.title}
Expand Down
Loading
Loading