diff --git a/.github/workflows/guard.yml b/.github/workflows/guard.yml index ba78d9c..3f16c04 100644 --- a/.github/workflows/guard.yml +++ b/.github/workflows/guard.yml @@ -148,8 +148,5 @@ jobs: - name: Generate contracts run: runseal :codegen - - name: Typecheck - run: pnpm typecheck - - name: Build run: pnpm build diff --git a/.runseal/hooks/pre-commit b/.runseal/hooks/pre-commit index 10e267c..1fd4ed7 100644 --- a/.runseal/hooks/pre-commit +++ b/.runseal/hooks/pre-commit @@ -32,8 +32,8 @@ if ! git diff --exit-code -- packages/contracts/openapi.json packages/contracts/ exit 1 fi -echo "==> typecheck" -pnpm typecheck +echo "==> lint" +pnpm lint echo "==> web build" pnpm build diff --git a/AGENTS.md b/AGENTS.md index 83b9d78..a9a3199 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,6 +119,13 @@ plane and must stay limited to cell lifecycle facts. missing prerequisite directly and stop that path. Do not spend time inventing fallbacks or exploring unrelated environment workarounds unless the user asks. - Keep hard cuts acceptable. Add compatibility only for a real external surface. +- Cargo and Node external dependencies must use exact stable versions only: + full version numbers such as `1.2.3`, no caret, tilde, range, wildcard, or + partial-version requirements. Workspace/local references remain workspace + indices or path/workspace entries (`workspace:*`, `workspace = true`, or + `path = ...`). Except for specifically documented defective releases, keep + dependencies on the latest stable version and update the lockfile in the same + change. ## Browser Automation Rules @@ -364,7 +371,6 @@ Current conventions: - `cargo fmt --all --check` - `flavor check --root . --config flavor.toml` - `cargo test --workspace` -- `pnpm -r --if-present typecheck` - `pnpm -r --if-present build` - `SANTI_DB=.tmp/manual.sqlite cargo run -p mini-stim-server-soma -- serve` - `cargo run -p mini-stim-client-soma -- dev` diff --git a/Cargo.lock b/Cargo.lock index 3ce6976..147e2a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,9 +150,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bumpalo" @@ -168,9 +168,9 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cc" -version = "1.2.63" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "jobserver", @@ -438,14 +438,17 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashlink" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "a5081f264ed7adee96ea4b4778b6bb9da0a7228b084587aa3bd3ff05da7c5a3b" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -456,9 +459,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -793,13 +796,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -817,9 +819,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libsqlite3-sys" -version = "0.38.0" +version = "0.38.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a76001fb4daed01e5f2b518aac0b4dc592e7c734da63dbffcf0c64fa612a8d0c" +checksum = "f6c19a05435c21ac299d71b6a9c13db3e3f47c520517d58990a462a1397a61db" dependencies = [ "cc", "pkg-config", @@ -840,9 +842,9 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "log" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lru-slab" @@ -858,9 +860,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "mime" @@ -1146,9 +1148,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -1169,9 +1171,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" @@ -1239,9 +1241,9 @@ dependencies = [ [[package]] name = "rusqlite" -version = "0.40.0" +version = "0.40.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b3492ea85308705c3a5cc24fb9b9cf77273d30590349070db42991202b214c4" +checksum = "11438310b19e3109b6446c33d1ed5e889428cf2e278407bc7896bc4aaea43323" dependencies = [ "bitflags", "fallible-iterator", @@ -1554,9 +1556,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" @@ -1866,9 +1868,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.23.2" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -1926,9 +1928,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" dependencies = [ "cfg-if", "once_cell", @@ -1939,9 +1941,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" dependencies = [ "js-sys", "wasm-bindgen", @@ -1949,9 +1951,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1959,9 +1961,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" dependencies = [ "bumpalo", "proc-macro2", @@ -1972,9 +1974,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" dependencies = [ "unicode-ident", ] @@ -2028,9 +2030,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" dependencies = [ "js-sys", "wasm-bindgen", @@ -2328,9 +2330,9 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2351,18 +2353,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index f0e278d..d7401f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,25 +18,25 @@ license = "MIT" version = "0.1.0" [workspace.dependencies] -async-stream = "0.3" -async-trait = "0.1" -axum = { version = "0.8", features = ["macros"] } -bytes = "1" -dotenvy = "0.15" -futures-core = "0.3" -futures-util = "0.3" -jiff = "0.2" +async-stream = "0.3.6" +async-trait = "0.1.89" +axum = { version = "0.8.9", features = ["macros"] } +bytes = "1.11.1" +dotenvy = "0.15.7" +futures-core = "0.3.32" +futures-util = "0.3.32" +jiff = "0.2.28" mini-stim-proto-client = { path = "proto/crates/client" } mini-stim-proto-server = { path = "proto/crates/server" } mini-stim-proto-transport = { path = "proto/crates/transport" } -reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "stream"] } -rusqlite = { version = "0.40", features = ["bundled"] } +reqwest = { version = "0.13.4", default-features = false, features = ["json", "rustls", "stream"] } +rusqlite = { version = "0.40.1", features = ["bundled"] } santi-core = { path = "apps/server/soma/crates/santi-core" } santi-provider = { path = "apps/server/soma/crates/santi-provider" } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } -tokio-stream = "0.1" -tower-http = { version = "0.6", features = ["cors", "trace"] } -utoipa = { version = "5", features = ["axum_extras"] } -uuid = { version = "1", features = ["v4"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +tokio = { version = "1.52.3", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } +tokio-stream = "0.1.18" +tower-http = { version = "0.6.11", features = ["cors", "trace"] } +utoipa = { version = "5.5.0", features = ["axum_extras"] } +uuid = { version = "1.23.3", features = ["v4"] } diff --git a/DESIGN.md b/DESIGN.md index 0d38678..1655ce8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -51,7 +51,71 @@ 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. -## 3. Templates, creativity, and patterns +## 3. Design pairing workflow + +Design work in `mini-stim` should treat the current local product surface as a +real validation field, not as a finished target and not as a detached mock. + +The preferred pairing loop is: + +1. user-facing observation +2. design translation +3. system rule +4. component landing point +5. live surface validation + +The user's primary role in this loop is to describe the experience from the +product and user side: + +- what feels unclear +- what feels visually weak or too strong +- what interaction feels blocked, noisy, or promising +- what product direction the surface should support next + +The design translation role is to convert those observations into professional +design-system language: + +- information hierarchy +- visual weight +- spatial rhythm +- typography role +- surface and boundary behavior +- interaction state grammar +- component ownership + +Engineering judgment is part of the loop, but it should support the design +translation rather than replace it. A useful engineering concern usually lands +as one of: + +- token scope +- atom capability +- icon policy +- pattern extraction +- product-component assembly +- temporary deferral because the product surface is not mature enough + +The expected output of a design discussion is not always code. It may be: + +- a rule added to this file +- a new or revised token +- a new atom capability +- a promoted pattern +- a focused product-surface change +- a deliberate decision to wait for more real usage + +The default quality bar is: + +- do not chase a complete visual redesign in one pass +- do not reduce design feedback to page-local CSS patches +- do not keep feedback as vague taste language when it can become a reusable + rule +- prefer small rules that can grow across the existing rail, chat, composer, + and Inspect surfaces + +This workflow exists so user intuition, design judgment, and implementation +constraints can converge into a durable design system. + +## 4. Templates, creativity, and patterns Template use and creativity are orthogonal. @@ -76,7 +140,7 @@ This means: When a local UI problem becomes structurally obvious, the correct action is usually to recognize the pattern and encode it, not to improvise again. -## 4. Pattern layer +## 5. Pattern layer `mini-stim` recognizes a layer between low-level design-system primitives and product-specific components. @@ -125,7 +189,7 @@ It should not express: Those are product-semantic usages of more general patterns. -## 5. Icon layer +## 6. Icon layer `mini-stim` also recognizes `icons` as a first-class design-system asset layer. @@ -154,7 +218,66 @@ Cold-starting from a mature icon set is acceptable. Long-term icon language may still evolve through replacement, curation, or custom additions. -## 6. Pattern lifecycle +## 7. Component responsibility boundaries + +Design-system quality depends on clear property ownership, not only on good +values. + +When a component looks wrong, first identify which layer is allowed to own the +wrong property. Do not patch the nearest visible file just because it can +technically express the CSS. + +The current responsibility matrix is: + +- tokens: + own raw and semantic values such as color, spacing, radius, typography, + border color, shadow values, and motion timing +- icons: + own glyph choice, stroke/fill language, icon naming, and size-scale policy +- atoms: + own intrinsic element behavior and styling such as frame, radius, internal + padding, tone variants, focus/hover/disabled state, scroll mechanics, and ref + attachment capability +- compound atoms: + own slot anatomy and internal chrome for a reusable object family, such as + `Panel.Root/Header/Body/Footer` +- patterns: + own business-blind relationships between multiple children, such as + fluid-vs-fixed regions, pinned actions, repeated row anatomy, and stable + spacing rhythm +- product components: + own product meaning, content, data binding, event wiring, inspection metadata, + and the choice of which atom or pattern to use +- app root: + owns cross-cutting application capabilities such as providers, inspection + registries, shadow-root overlays, and global context hooks +- layout grid: + owns page-level placement, column/row sizing, gaps between major regions, and + resize boundaries + +This matrix is allowed to evolve, but it should converge toward fewer +surprises, not toward local exceptions. + +Use these tests when deciding where a property belongs: + +- if the same visual defect appears in multiple product components, move the + fix down into an atom, compound atom, pattern, or token +- if a fix depends on product meaning or copy, keep it in product assembly +- if a property changes the outside relationship between siblings, consider a + pattern or layout owner +- if a property changes the inside anatomy of one reusable object family, change + the compound atom +- if a cross-cutting capability needs global coordination, route it through + `AppRoot` or a context hook instead of explicit wrapper components +- if a component needs exactly one child to smuggle behavior into the tree, + treat that as an ownership smell and audit it against the single-child rule + +The `Panel` frame-line correction is the current concrete example: +the visual defect appeared across primary panels, but depended on parent +overflow clipping. The fix therefore belonged in `Panel.Root` as an internal +frame boundary, not in the session rail, chat shell, Inspect panel, or grid. + +## 8. Pattern lifecycle Patterns are design assets, not sacred artifacts. @@ -182,7 +305,7 @@ The default storage rule is: If a pattern is stable enough to trust, the preferred outcome is to encode it in `packages/components`. -## 7. Computed geometry +## 9. Computed geometry Not every useful design token should be authored as an isolated final value. @@ -217,7 +340,7 @@ Current shell-level rule: - expand this approach to other layers only when repeated proportional issues show that the relationship is stable enough to formalize -## 8. Current aesthetic direction +## 10. Current aesthetic direction The current direction is: @@ -237,7 +360,7 @@ The closest useful inspiration pattern is: The wrong takeaway from editorial references is warm paper nostalgia. The right takeaway is restraint, hierarchy, and accent discipline. -## 9. Color philosophy +## 11. Color philosophy ### Base rule @@ -294,7 +417,7 @@ Do not drift into: Warm accents may exist, but the workspace itself should remain clear and clean. -## 10. Surface hierarchy +## 12. Surface hierarchy The product should read as a stack of clear working layers: @@ -318,7 +441,283 @@ Not by: Shadows should be minimal. Borders and tonal separation do most of the work. -## 11. Typography roles +### Surface skeleton rule + +The interface must not feel like a blank white sheet. + +Restraint means quiet hierarchy, not missing hierarchy. A user should be able +to identify the following layers at a glance without needing saturated color: + +- workspace canvas +- chrome panel +- panel interior / inset work area +- raised or selectable item +- focused or selected state +- docked input/control area + +Each layer should have a small but detectable material difference through some +combination of: + +- lightness shift +- border weight +- internal highlight +- contact shadow +- selected indicator + +Do not solve this by adding decorative color. Solve it by making existing +surfaces carry their intended hierarchy. + +The first cold-start surface pass should prioritize: + +- distinguishing the app canvas from panel chrome +- keeping panels low-noise but not flat +- giving inset transcript/Inspect empty regions a deliberate tone +- making selected rail/message surfaces feel intentionally held +- keeping the composer dock visibly interactive without becoming a second + command bar + +### Material and shape grammar + +Once the basic surface skeleton is visible, the next step is not more generic +contrast. The next step is product material grammar. + +Each recurring surface role should have a recognizable shape idiom: + +- app canvas: + broad warm-neutral workspace field, never pure white, with quiet ambient + material variation +- chrome panel: + quiet contained shell with a soft contact edge and slight internal highlight +- inset work area: + cooler recessed field for transcript, Inspect, and other scrollable work +- selectable item: + raised light object with clear perimeter and subtle contact shadow +- selected item: + held object with stronger tint, firmer border, and one directional indicator +- assistant content: + readable content block with a soft neutral/material tint +- account content: + authored bubble with restrained accent tint, not saturated social-chat color +- tool whisper: + compact utility trace that feels secondary and inspectable +- composer dock: + active input surface with stronger affordance than passive panels + +The design should avoid a single repeated "white box with 1px border" solution. +If two elements have different interaction or information roles, they should +not rely on identical surface treatment unless the sameness is intentional. + +The palette should remain disciplined, but not monochrome. It may use restrained +temperature differences: + +- cooler canvas and inset work fields +- clean warm-neutral raised panels and inputs +- controlled blue accent for action and selection +- faint green/teal cast only where it supports assistant or utility material + +Do not let temperature variety become decoration. Its job is to make surface +roles legible. + +### Canvas material rule + +The app canvas is a material field, not a flat painted backdrop. + +It may use low-contrast linear gradients, subtle line-grain texture, and ambient +flow to keep the workspace from feeling dead. This motion must stay below the +user's conscious task focus when they are reading or acting in the product, but +it must still be legible when the user deliberately watches the canvas: + +- use broad linear material fields rather than discrete decorative shapes +- avoid orbs, blobs, bokeh, or illustrative background objects +- animate with a detectable direction and rhythm, not an almost-static drift +- keep the movement slow and broad enough that it feels ambient, not like + interface feedback or a loading state +- when warm translucent panels need clearer motion visibility, the canvas may + use a muted mineral/lichen neutral family; this should read as low-chroma + material separation, not a green theme, success signal, nature motif, or + saturated decorative background +- keep panel separation readable; canvas material must sit behind the panels +- respect `prefers-reduced-motion: reduce` by stopping infinite motion + +The current implementation lives in `AppRoot`, because canvas material is an +application shell capability, not a product component concern. + +### Interaction entry rule + +The first readable screen must feel operable before it feels explainable. + +Avoid static presentation surfaces that make the product look like a slide. +The main chat area does not need starter buttons just to prove that chat is +available. When the composer is visible, the chat affordance is already direct. +Avoid repeating that entry point with a card of prompt choices in the +transcript. + +Do not use raw runtime ids as the main product title for ordinary empty +conversations. Runtime ids may appear in Inspect or diagnostics, but the chat +surface should use human-facing names such as an explicit title, a preview, or +`Untitled chat`. + +Transcript empty states should be background material, not cards. Use a quiet +centered embossed text treatment for the empty work field; keep the actual +action in the composer or the rail. + +### Rail identity entry rule + +The session rail header is an identity entry surface, not a brand billboard. + +For the current product direction, persistent rail-top copy should favor the +active Soul identity over the product name. `mini-stim` may remain the document +title or system-level identity, but it should not dominate the working rail +chrome when a more useful interaction anchor exists. + +Current shell structure: + +- `AppRoot` owns the animated canvas but not the shell padding +- a fixed-width DockShell is the leftmost full-height app shell region +- DockShell contains: + - a full-height Dock lane + - an inner GridShell that owns the main workspace padding +- Dock lane width is a DockShell/component-token decision, not product App + assembly state. The current reviewed width is `48px`. +- Dock lane padding should be tighter than workspace padding because it is a + narrow persistent control strip +- Dock lane padding may be asymmetric: the current direction is tighter inline + padding than the workspace, with a slightly larger top inset so the first + dock action does not feel pinned to the viewport edge +- DockShell owns the narrow Dock lane's material and horizontal control + centering; product dock components should not use a generic Pane wrapper to + simulate the lane surface +- GridShell must keep left padding between the Dock lane and the first primary + panel; do not remove left padding just because the Dock lane already occupies + the left edge +- Navigation Dock switches first-level rail modes such as Sessions and Souls +- the dock is not an overlay and not a child of Session Rail +- the inner Grid owns rail/main/inspect placement +- the resizable rail column remains separate from the fixed Dock lane +- Session Rail header remains a stable Soul nickname anchor +- Soul nickname is an identity label/select shortcut, similar to the Chat Header + title label/input rhythm; it should not be the rail-mode trigger +- Rail Body renders the active dock mode: + - Sessions mode: session list plus New Session action + - Souls mode: Soul list plus New Soul management affordance +- mode-local create actions share the same rail grammar: a compact top row with + the mode label/count on the left and the create button on the right. New Soul + may be disabled, but it should keep the same shape and placement as New + Session. +- New Soul may remain disabled until the data/model action exists, but the + management affordance should live in Souls mode, not in the normal Sessions + face + +Session Rail and Chat Shell headers should share the same header height rhythm. +If one header needs additional semantics, compress the local structure before +changing the shared `Panel.Header` padding or letting one primary panel drift. + +The panel shell and Soul header should remain stable during mode changes. +Dock-driven mode changes replace the Rail Body content directly; they should +not use a two-faced flip surface, rotate the `Panel.Root`, restyle DockShell, or +move the identity anchor. + +New Session is not a rail-brand action. It belongs to the sessions region, near +the list it creates and the session count it changes. + +Rail refs and accessibility state should come from the rendered active body +content. Hidden inactive rail faces should not remain mounted only to support a +mode animation. + +### Semantic label overlay + +During product-surface design, business components may expose a consistent +floating semantic label in their top-right corner through AppRoot inspection. + +This is not decorative UI and not end-user product chrome. It is a shared +semantic-space tool for aligning product, design, and engineering judgment +around component boundaries. + +Rules: + +- the overlay is controlled centrally by `AppRoot` +- all labels use one visual treatment and one top-right placement rule +- business components register structured metadata through `useAppComponentRef` +- registration attaches to the smallest real root DOM node the component owns +- atoms and low-level patterns do not invent product labels +- labels should stay inside the viewport and may collision-shift when narrow or + edge-adjacent components would otherwise clip the label +- labels should expose boundary problems rather than hiding them +- overlay rendering is internal to `AppRoot`; callers do not mount an explicit + overlay layer + +If a label feels misplaced, too noisy, or attached to the wrong region, treat +that as evidence that the component boundary or visual hierarchy needs review. + +### Primary container rule + +Primary workspace panels use the `Panel.*` compound atom family. + +`Panel.Root`, `Panel.Header`, `Panel.Body`, and `Panel.Footer` are the first +container truth source for the session rail, chat shell, and Inspect panel. +Product components may decide their content, but they should not re-decide the +first-level panel chrome, radius, shadow, boundary, or header/body/footer +geometry. + +This is intentionally an atom-level compound component rather than a product +pattern. It keeps the visual shell business-blind while allowing product +assemblies to register their own inspection metadata on the actual +`Panel.Root`. + +Rules: + +- do not wrap a primary panel in another visual container to repair shape drift +- if the shared panel shape is wrong, change `Panel.*` +- if a use case needs a genuinely different container language, introduce a + sibling family such as `Card.*` or `Cell.*` instead of weakening `Panel.*` +- internal product content may differentiate through surface tone and layout, + but the first-level shell remains shared +- primary panel edges should not rely on a hard outer border. The current + direction is borderless material separation: warm shell color, soft contact + shadow, subtle internal highlight, and broad edge shading +- borderless does not mean low contrast. The canvas must sit visibly behind the + panel shell through a clear warm-neutral value step plus enough contact shadow + for the panel to read as a separate working surface +- inset body material must not paint into the outer shell radius; it may create + an internal work field, but the rounded primary chrome belongs to `Panel.Root` +- tight spacing between primary panels is intentional; discomfort should be + solved with better material transition, frame softness, and shadow behavior, + not by reflexively increasing the gap +- current panel material direction is warm neutral: a calm warm-gray shell, + subtle internal highlight, grounded but short shadow, and a softer inset field + that feathers into the shell instead of reading as a hard pasted rectangle +- panels may use translucent material backgrounds so the AppRoot canvas remains + perceptible behind the primary work surface; do not use element `opacity` for + this because it weakens text, controls, and semantic content +- if the canvas motion cannot be perceived through panels, prefer lowering the + panel material stop opacity and increasing material blur before changing text + opacity or adding hard frame effects +- the panel shell background may carry a very small diagonal material gradient: + roughly 20 degrees counterclockwise from the vertical axis, with a restrained + value range from full warm shell to about 80% shell weight +- `Panel.Body tone="inset"` owns the internal work field through Panel-level + material tokens; product components should not create local inset patches to + repair panel comfort + +### Single-child topology rule + +Avoid introducing a component whose only job is to wrap exactly one child. + +Single-child wrappers hide ownership, create false visual boundaries, and make +semantic inspection drift away from the real component root. The preferred +solutions are: + +- enhance the existing component so the required behavior lands on the real + element +- split or promote a reusable atom family when the behavior is genuinely + reusable + +Exceptions require an explicit reason, such as provider context, error +boundary, portal/shadow-root ownership, measurement, animation, accessibility +adapter, foreign library adapter, or another documented strict ownership +boundary. + +## 13. Typography roles Typography has distinct jobs. @@ -364,7 +763,7 @@ Use for: Mono is not a theme. It is a utility lane. -## 12. Component translation rules +## 14. Component translation rules Visual language must land in the component system, not in page-local styling. @@ -456,7 +855,7 @@ when those rules already belong in atoms or patterns. If `web` appears to need CSS, assume atom expressiveness is missing. Add or refine atom/pattern capability instead of patching page-local styles. -## 13. Pattern index and provisional storage +## 15. Pattern index and provisional storage This file also acts as the pattern index. @@ -541,7 +940,7 @@ Current policy: - future icon review may rename, replace, or expand this set without exposing raw third-party imports as the system contract -## 14. Message styling rules +## 16. Message styling rules Messages should be visually distinct by role, but still belong to one family. @@ -567,7 +966,7 @@ Messages should be visually distinct by role, but still belong to one family. - should feel structured and inspectable - code/payload areas should remain mono and neutral enough for scanning -## 15. Motion and interaction +## 17. Motion and interaction Motion should be sparse and useful. @@ -585,7 +984,7 @@ Do not use motion for: The interface should feel responsive, not animated. -## 16. Background and atmosphere +## 18. Background and atmosphere The product may have a small amount of atmosphere, but it must stay behind the work. @@ -605,7 +1004,7 @@ Not allowed: If a background effect is noticeable before the content is noticeable, it is too strong. -## 17. Borrowing from external references +## 19. Borrowing from external references When studying another project, extract only stable principles such as: @@ -630,7 +1029,7 @@ The question is: "What visual rule from that project is durable enough to become a token, atom, or pattern capability here?" -## 18. Review heuristics +## 20. Review heuristics A visual change is likely correct when: @@ -650,7 +1049,7 @@ A visual change is likely wrong when: - it leaves a recurring high-constraint local structure trapped in page-level JSX instead of promoting it to a reusable pattern -## 19. Near-term direction for mini-stim +## 21. Near-term direction for mini-stim Near-term refinement should focus on: @@ -670,7 +1069,7 @@ Not on: - dark mode expansion before the light mode language is stable - high-brand marketing aesthetics inside the working chat surface -## 20. Maintenance rule +## 22. Maintenance rule When `mini-stim` gains new visual capabilities, update this file if the change alters: diff --git a/apps/client/soma/web/package.json b/apps/client/soma/web/package.json index 4aa01d4..98e342e 100644 --- a/apps/client/soma/web/package.json +++ b/apps/client/soma/web/package.json @@ -5,20 +5,24 @@ "scripts": { "build": "tsc && vite build", "dev": "vite --host 127.0.0.1 --port 41420", - "typecheck": "tsc --noEmit" + "lint": "biome check .", + "test": "vitest run --config vitest.config.ts" }, "dependencies": { "@mini-stim/components": "workspace:*", "@mini-stim/hooks": "workspace:*", - "@vitejs/plugin-react": "^6.0.2", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "typescript": "^6.0.3", - "vite": "^8.0.16" + "@mini-stim/storage": "workspace:*", + "@vitejs/plugin-react": "6.0.2", + "react": "19.2.7", + "react-dom": "19.2.7", + "typescript": "6.0.3", + "vite": "8.0.16" }, "devDependencies": { - "@types/react": "^19.2.16", - "@types/react-dom": "^19.2.3", - "sass": "^1.94.0" + "@biomejs/biome": "2.5.0", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "sass": "1.101.0", + "vitest": "4.1.8" } } diff --git a/apps/client/soma/web/src/App.tsx b/apps/client/soma/web/src/App.tsx index 999b974..62e7166 100644 --- a/apps/client/soma/web/src/App.tsx +++ b/apps/client/soma/web/src/App.tsx @@ -1,6 +1,6 @@ -import { useEffect, useMemo, useState } from "react"; -import { AppRoot, Grid, GridItem } from "@mini-stim/components"; +import { AppRoot, DockShell, Grid, GridItem, ResizeHandle } from "@mini-stim/components"; import { + type SessionSummary, useDebouncedValue, useMessageConnection, useSelectedSessionId, @@ -9,14 +9,29 @@ import { useSessionPending, useSessionPreviews, useSessionRuntime, - useSessionTurnTimeline, useSessions, + useSessionTurnTimeline, } from "@mini-stim/hooks"; +import { uiStorage } from "@mini-stim/storage"; +import { type PointerEvent as ReactPointerEvent, 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 { NavigationMode } from "./navigationMode"; + +const DEFAULT_RAIL_WIDTH = 304; +const DEFAULT_INSPECT_WIDTH = 352; +const RAIL_WIDTH_RANGE = { min: 264, max: 420 }; +const INSPECT_WIDTH_RANGE = { min: 288, max: 520 }; export function App() { + const [preferences] = useState(() => uiStorage.read()); + const [layout, setLayout] = useState(preferences.desktopLayout); + const [resizing, setResizing] = useState<"inspect" | "rail" | null>(null); const sessions = useSessions(); const selectedSessionId = useSelectedSessionId(); const timeline = useSessionTurnTimeline(); @@ -28,20 +43,71 @@ export function App() { const previews = useSessionPreviews(); const [draft, setDraft] = useState(""); const [error, setError] = useState(null); - const [inspecting, setInspecting] = useState(false); + const [inspecting, setInspecting] = useState(preferences.inspect.open); + const [inspectTarget, setInspectTarget] = useState(null); + const [navigationMode, setNavigationMode] = useState("sessions"); - // The inspect panel is a view over the selected session; switching - // sessions returns to the transcript. Opening it refreshes the snapshot - // so memory/compacts/effects reflect the turns since selection. useEffect(() => { - setInspecting(false); - }, [selectedSessionId]); + if (!selectedSessionId && sessions.length) { + actions.selectAndGet(sessions[0].session.id); + } + }, [actions, selectedSessionId, sessions]); + + // Inspect is a remembered view over the selected session. Keeping it open + // across session changes refreshes the snapshot for the newly selected + // session instead of returning to the transcript. + useEffect(() => { + if (inspecting && selectedSessionId) { + actions.refreshRuntime(selectedSessionId); + } + }, [actions, inspecting, selectedSessionId]); - function toggleInspect() { - if (!inspecting && selectedSessionId) { + 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, + }, + })); + }), + [actions, selectedSessionId], + ); + + function openInspect() { + setInspectTarget(null); + if (selectedSessionId) { actions.refreshRuntime(selectedSessionId); } - setInspecting((current) => !current); + setInspecting(true); + uiStorage.update((current) => ({ + ...current, + inspect: { + ...current.inspect, + open: true, + }, + })); + } + + function closeInspect() { + setInspecting(false); + uiStorage.update((current) => ({ + ...current, + inspect: { + ...current.inspect, + open: false, + }, + })); } const busy = pending > 0; @@ -78,19 +144,21 @@ export function App() { [timeline], ); const sessionErrorMessage = - sessionError && !inPlaceErrors.has(sessionError.message) - ? sessionError.message - : null; + sessionError && !inPlaceErrors.has(sessionError.message) ? sessionError.message : null; const visibleError = error ?? sessionErrorMessage; const debouncedConnection = useDebouncedValue(connection, { debounceMs: 150 }); const selectedTitle = useMemo(() => { - const selected = sessions.find((session) => session.id === selectedSessionId); + const selected = sessions.find((session) => session.session.id === selectedSessionId); return selected ? sessionLabel(selected) : "New session"; }, [selectedSessionId, sessions]); const selectedSession = useMemo( - () => sessions.find((session) => session.id === selectedSessionId) ?? null, + () => sessions.find((session) => session.session.id === selectedSessionId) ?? null, [selectedSessionId, sessions], ); + const soulIdentity = { + avatarSeed: runtime?.soul_profile?.avatar_seed ?? "soul_default", + name: runtime?.soul_profile?.nickname ?? "Santi", + }; function createNewSession() { setError(null); @@ -103,6 +171,7 @@ export function App() { function selectSession(sessionId: string) { setError(null); + setInspectTarget(null); try { actions.selectAndGet(sessionId); } catch (caught) { @@ -112,7 +181,7 @@ export function App() { function send() { const text = draft.trim(); - if (!text || busy) { + if (!text || busy || !selectedSessionId) { return; } setError(null); @@ -140,43 +209,128 @@ export function App() { } } + function beginRailResize(event: ReactPointerEvent) { + beginResize("rail", event); + } + + function beginInspectResize(event: ReactPointerEvent) { + beginResize("inspect", event); + } + + function beginResize(target: "inspect" | "rail", event: ReactPointerEvent) { + event.preventDefault(); + const startX = event.clientX; + const startWidth = + event.currentTarget.parentElement?.getBoundingClientRect().width ?? + (target === "rail" ? DEFAULT_RAIL_WIDTH : DEFAULT_INSPECT_WIDTH); + let nextWidth = startWidth; + setResizing(target); + + function handlePointerMove(moveEvent: PointerEvent) { + const delta = moveEvent.clientX - startX; + nextWidth = clampWidth(target === "rail" ? startWidth + delta : startWidth - delta, target); + setLayout((current) => ({ + ...current, + railWidthPx: target === "rail" ? nextWidth : current.railWidthPx, + inspectWidthPx: target === "inspect" ? nextWidth : current.inspectWidthPx, + })); + } + + function handlePointerUp() { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + setResizing(null); + uiStorage.update((current) => ({ + ...current, + desktopLayout: { + ...current.desktopLayout, + railWidthPx: target === "rail" ? nextWidth : layout.railWidthPx, + inspectWidthPx: target === "inspect" ? nextWidth : layout.inspectWidthPx, + }, + })); + } + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + } + return ( - - - - - - - - - + + + + + + + + + + + + + + + {inspecting ? ( + + + + + ) : null} + + + ); } -function sessionLabel(session: { id: string; title?: string | null }) { - return session.title?.trim() || session.id; +function sessionLabel(session: SessionSummary) { + return session.profile.title?.trim() || "Untitled chat"; +} + +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))); } diff --git a/apps/client/soma/web/src/appNamespace.ts b/apps/client/soma/web/src/appNamespace.ts new file mode 100644 index 0000000..04bfbba --- /dev/null +++ b/apps/client/soma/web/src/appNamespace.ts @@ -0,0 +1 @@ +export const STIM_APP_NAMESPACE = "mini-stim/stim"; diff --git a/apps/client/soma/web/src/components/ChatHeader.tsx b/apps/client/soma/web/src/components/ChatHeader.tsx index 567e474..f35833b 100644 --- a/apps/client/soma/web/src/components/ChatHeader.tsx +++ b/apps/client/soma/web/src/components/ChatHeader.tsx @@ -1,28 +1,38 @@ -import { useEffect, useState } from "react"; - import { Badge, Button, - Input, Inline, - Pane, + Input, Stack, Text, + useAppComponentRef, } from "@mini-stim/components"; +import { useEffect, useState } from "react"; + +import { STIM_APP_NAMESPACE } from "../appNamespace"; export function ChatHeader(props: { activity: string; busy: boolean; connection: string; inspecting: boolean; + onOpenInspect: () => void; onTitleCommit: (title: string | null) => void; - onToggleInspect: () => void; selectedSessionId: string | null; title: string; titleValue: string | null; }) { const [draft, setDraft] = useState(props.title); const [editing, setEditing] = useState(false); + const headerRef = useAppComponentRef({ + domain: "chat", + id: "chat-header", + kind: "section", + label: "Chat Header", + namespace: STIM_APP_NAMESPACE, + projection: "header", + surface: "chat shell", + }); useEffect(() => { if (!editing) { @@ -45,67 +55,71 @@ export function ChatHeader(props: { } return ( - - - - {editing ? ( - setDraft(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - event.currentTarget.blur(); - } - if (event.key === "Escape") { - setEditing(false); - setDraft(props.title); - } - }} - /> - ) : ( - - )} - - - {props.busy ? ( - {props.activity} - ) : null} - {props.connection === "error" ? ( - reconnecting - ) : null} + + + {editing ? ( + setDraft(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.blur(); + } + if (event.key === "Escape") { + setEditing(false); + setDraft(props.title); + } + }} + /> + ) : ( + + )} + + + {props.busy ? ( + + {props.activity} + + ) : null} + {props.connection === "error" ? ( + + reconnecting + + ) : null} + {props.inspecting ? null : ( - + )} - + ); } diff --git a/apps/client/soma/web/src/components/ChatShell.tsx b/apps/client/soma/web/src/components/ChatShell.tsx index 61f40fe..fb33988 100644 --- a/apps/client/soma/web/src/components/ChatShell.tsx +++ b/apps/client/soma/web/src/components/ChatShell.tsx @@ -1,9 +1,8 @@ -import { Pane, SectionStackLayout } from "@mini-stim/components"; -import type { SessionRuntimeSnapshot } from "@mini-stim/hooks"; +import { Panel, useAppComponentRef } from "@mini-stim/components"; +import { STIM_APP_NAMESPACE } from "../appNamespace"; import { ChatHeader } from "./ChatHeader"; import { Composer } from "./Composer"; -import { InspectPanel } from "./InspectPanel"; import { Transcript } from "./Transcript"; export function ChatShell(props: { @@ -13,49 +12,53 @@ export function ChatShell(props: { error: string | null; inspecting: boolean; onDraftChange: (value: string) => void; + onOpenInspect: () => void; onSend: () => void; onTitleCommit: (title: string | null) => void; - onToggleInspect: () => void; - runtime: SessionRuntimeSnapshot | null; selectedSessionId: string | null; + soulIdentity: Parameters[0]["soulIdentity"]; title: string; titleValue: string | null; timeline: Parameters[0]["timeline"]; draft: string; }) { + const shellRef = useAppComponentRef({ + domain: "chat", + id: "chat-shell", + kind: "panel", + label: "Chat Shell", + namespace: STIM_APP_NAMESPACE, + projection: "primary panel", + surface: "workspace", + }); + return ( - - - )} - middle={ - props.inspecting ? ( - - ) : ( - - ) - } - bottom={( - - )} - /> - + + + + + + + + + + + ); } diff --git a/apps/client/soma/web/src/components/Composer.tsx b/apps/client/soma/web/src/components/Composer.tsx index b720afd..6dc8915 100644 --- a/apps/client/soma/web/src/components/Composer.tsx +++ b/apps/client/soma/web/src/components/Composer.tsx @@ -1,4 +1,15 @@ -import { Button, Inline, Notice, SendIcon, Stack, Text, TextArea } from "@mini-stim/components"; +import { + Button, + FieldActionLayout, + Notice, + SendIcon, + Stack, + Text, + TextArea, + useAppComponentRef, +} from "@mini-stim/components"; + +import { STIM_APP_NAMESPACE } from "../appNamespace"; export function Composer(props: { disabled?: boolean; @@ -7,8 +18,19 @@ export function Composer(props: { onSubmit: () => void; value: string; }) { + const composerRef = useAppComponentRef({ + domain: "message", + id: "composer", + kind: "control", + label: "Composer", + namespace: STIM_APP_NAMESPACE, + projection: "input", + surface: "chat shell", + }); + return (
{ event.preventDefault(); props.onSubmit(); @@ -20,18 +42,8 @@ export function Composer(props: { {props.error} ) : null} - - -