Skip to content

feat(laymos): within-layer module groups + module-tile highlight fix - #20

Open
kishorenuma wants to merge 11 commits into
mainfrom
claude/lemos-module-hierarchy-tobive
Open

feat(laymos): within-layer module groups + module-tile highlight fix#20
kishorenuma wants to merge 11 commits into
mainfrom
claude/lemos-module-hierarchy-tobive

Conversation

@kishorenuma

@kishorenuma kishorenuma commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Why

The module view removes file-level noise, but a layer with twenty modules is still twenty boxes — the flat list overwhelms. Layers don't have this problem because a layer never floats alone: it lives inside a Layer Graph, the handful-sized bundle a reader actually scans. Modules had no equivalent tier. This PR adds one, and fixes a graph-repaint bug found along the way.

Module Groups — a within-layer disclosure tier

A Module Group is a named, described cluster of sibling modules that all live in the same layer. It is to modules what a Layer Graph is to layers, scoped to a single layer so it nests instead of forming a second hierarchy:

Layer  →  Group  →  Module  →  file evidence

Design write-up: devtools/laymos/docs/adr/0020-module-groups.md.

Config API

Mirrors the moduleRules convention — members reuse values already declared in modules, so a module is never declared twice:

const orders = module('src/domain/orders', { description: 'Order rules' });
const cart   = module('src/domain/cart',   { description: 'Cart rules' });

export default defineConfig({
  // …graphs…
  modules: [orders, cart],
  moduleGroups: [
    group('orders', [orders, cart], { description: 'Order lifecycle' }),
  ],
});

Invariants (both are config errors when broken)

  • Same layer — every member resolves (by longest layer prefix) to one layer. This is what makes a group nest under its layer lane rather than cut across layers.
  • At most one group per module — groups partition a layer's modules, exactly as layers partition files and modules stay flat.

Plus: unique group names, non-empty description, non-empty membership, and members must reuse declared modules values. Groups are purely organizational — they impose no import rule; canImport/canImportedBy stay module-level.

Rendering — progressive disclosure (collapse to a node)

This is the point of the tier, so the UI treats it as one:

  • A group collapses to a single node by default. A crowded layer reads as a handful of group nodes (plus any ungrouped module tiles), not every module at once.
  • Click a group to expand it into a labeled band of its member tiles; click the header to collapse again. Same collapse/expand affordance the layer lanes already have.
  • Edges follow the disclosure. A selected module's edges route into a collapsed group node and fan out to the individual members once it's expanded (endpointId resolves layer → group → module). Selecting a module dims unrelated groups and highlights related ones.
  • Layer width is unchanged, so lanes stay put across expand/collapse; only height reflects what's open. Configs without groups render exactly as before.

Try it in cosmos → laymos-modulesgrouped architecture (a dense report whose application and domain layers are grouped, with an ungrouped tail in application and untouched entry/platform).

Where it flows

  • laymosgroup() builder + ModuleGroup type + validation.
  • Report — an always-present moduleGroups registry (name → { description, modules }), empty when unused. The group's layer is inferred by consumers, the way a module's layer already is.
  • Frontend — the model exposes groups / groupByModule and layers carry groupNames; the packed layout collapses/expands groups per the above.

Bug fix: graph didn't repaint until a drag

Clicking a module updated the selection and recomputed the layout, but the canvas — the appearing edges and the node highlighting — didn't repaint until an unrelated pan or drag "revealed" the already-computed state.

Root cause: the graph passed derived nodes/edges to <ReactFlow> as read-only props, so xyflow synced them into its store through a post-paint effect and could leave the canvas a commit behind each state change until a viewport event flushed it.

Fix: make it a properly controlled flow. A small useFlowElements hook owns xyflow's node/edge state (useNodesState/useEdgesState), pushes each new computed layout in a useLayoutEffect (pre-paint), and wires onNodesChange/onEdgesChange. The computed layout stays the single source of truth; xyflow now repaints selection state on the same commit as the click. (The module tiles also read selected/related/dimmed from node data — xyflow's reactive channel — rather than from React context.)

Considered & rejected

  • Nesting modules inside modules — true hierarchy, but breaks the flat-partition invariant that longest-prefix ownership, coverage, and rule evaluation rely on.
  • Cross-layer "feature" grouping — matches the "connected for a feature" intent, but then layers and features are two hierarchies over the same modules and the picture can only show one. A within-layer group is the smallest change that removes the overwhelm while keeping every invariant; a cross-layer feature tag stays additive later.

Testing

  • laymos: pnpm build && pnpm lint (format + eslint + tsc + CLI self-lint) and pnpm test83 passed, incl. 6 group-validation cases and a report-emission case.
  • frontend: vitest on laymos-modules29 passed, incl. model group tests and layout tests that lock collapse, expand, and edge-into-group routing; laymos-layers 21 passed; vp check clean on touched files.

Note: the grouped geometry and the repaint timing are verified by construction + the layout tests rather than visually (no UI in the build env) — the cosmos "grouped architecture" fixture makes it a quick check.

🤖 Generated with Claude Code

…light

Introduce Module Groups, a within-layer disclosure tier that clusters
sibling modules the way a Layer Graph clusters layers. A group is a
named, described set of modules that all live in the same layer, so the
module view reads as a handful of labeled clusters (Layer > Group >
Module) instead of a flat list.

Core (laymos):
- `group(name, modules, { description })` builder and `ModuleGroup` type
- optional `moduleGroups` config field; members reuse values already
  declared in `config.modules`, mirroring module rules
- validation: same-layer members, at-most-one-group membership, unique
  names, non-empty description, non-empty membership, reused values
- report gains an always-present `moduleGroups` registry
- CONTEXT glossary entry and README usage

Frontend:
- model exposes `groups` and `groupByModule`; layers carry `groupNames`
- packed layout renders each group as a labeled band within its layer
  container, with an ungrouped tail; configs without groups are unchanged
- fix: module tiles now read selection state (selected/related/dimmed)
  from node data instead of React context, so clicking a module repaints
  the node highlight immediately instead of only after a pan/drag

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ma9T44AwqyiidS5XrwcvVL
@changeset-bot

changeset-bot Bot commented Jul 25, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 978a3da

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9039c6b82e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +278 to +280
violationCount: memberPaths.reduce(
(total, path) => total + (modules.get(path)?.violationCount ?? 0),
0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Count each intra-group violation once

When a violating file import connects two modules in the same group, this sum counts the violation once for each endpoint because incidentViolations increments both edge.from and edge.to. The group header therefore displays 2 violations for a single invalid import; derive the group total directly from the violating file edges touching its members, deduplicating edges whose endpoints are both inside the group.

Useful? React with 👍 / 👎.

claude added 10 commits July 25, 2026 04:25
Add `groupedModuleArchitectureReport` — a dense report whose crowded
`application` and `domain` layers are chunked with Module Groups,
including an ungrouped tail in `application` and untouched `entry` /
`platform` layers so grouped bands, the bare tail, and mixed layers all
render in one view. Wire it into the cosmos fixture as
"grouped architecture" and lock its shape with a model test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ma9T44AwqyiidS5XrwcvVL
Replace the always-expanded labeled bands with real progressive
disclosure. A group now collapses to a single node by default, so a
crowded layer reads as a handful of group nodes (plus any ungrouped
tiles) instead of every module at once. Clicking a group expands it into
a labeled band of its member tiles; clicking the header collapses it
again.

- layout: `layerGroupLayout` packs collapsed group nodes and ungrouped
  tiles into the layer grid, and stacks expanded groups as bands below;
  height reflects `expandedGroups`, width is unchanged so lanes stay
  stable across expand/collapse
- edges: a selected module's edges route into a collapsed group node and
  fan out to members once expanded (`endpointId` resolves layer >
  group > module)
- one `module-group` node renders both states (collapsed card with
  handles + count, expanded header with collapse affordance); toggled via
  `onToggleGroup` on the interaction context
- groups collapsed by default; state resets on report change
- layout tests lock collapse, expand, and edge-into-group routing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ma9T44AwqyiidS5XrwcvVL
Selection highlights (and the edges that appear on selection) lagged
until an unrelated pan or drag. Root cause: the graph passed derived
`nodes`/`edges` as read-only props, so xyflow synced them through a
post-paint effect and left the canvas a frame behind each state change
until a viewport event flushed the store.

Make it a properly controlled flow. `useFlowElements` owns xyflow's
node/edge state via `useNodesState`/`useEdgesState`, pushes each new
computed layout in a `useLayoutEffect` (pre-paint), and wires
`onNodesChange`/`onEdgesChange`. The layout stays the single source of
truth; xyflow now repaints selection state on the same commit as the
click that caused it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ma9T44AwqyiidS5XrwcvVL
Expanding a group no longer relocates its members to a labeled band at
the bottom of the layer. The group now opens where it sits: collapsed
groups and ungrouped modules flow as tile rows, and an expanded group
becomes a full-width nested container in that same flow, holding its
member tiles behind its own header. The header carries the group name,
its counts, and a minimise button (top-right) that collapses it again.

- layout: `layerGroupLayout` walks layer items in order and splits them
  into tile-row blocks and in-place container blocks; container members
  pack inside the box, width stays stable, height reflects what's open
- new `module-group-container` background node (header + minimise);
  `module-group` is now collapsed-only
- member tiles render above the container (layer-parented, higher
  z-index); edges still resolve layer > collapsed-group > module
- layout tests updated to assert the container/collapsed split

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ma9T44AwqyiidS5XrwcvVL
A control-panel switch (shown only when the report declares groups)
expands every group at once. Turning it on force-expands all groups;
turning it off hands control back to manual so the reader can collapse
each one individually. Clicking any single group drops out of "expand
all" and back into manual state. State resets with the report.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ma9T44AwqyiidS5XrwcvVL
… bands

Inside an expanded group container, members now sort by their
group-internal edges: seeds (nothing else in the group imports them) on
top, the connective core in the middle, leaves (they import nothing else
in the group) at the bottom, separated by labeled hairlines. The
in-between modules read apart from the group's entry and exit points.
Single-band groups render unlabeled, exactly as before. The grouped
fixture gains a few intra-group imports so the bands are exercised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ma9T44AwqyiidS5XrwcvVL
…buttons

Swap the "Expand all groups" switch for two explicit actions — "Expand
all" and "Collapse all". Expand all is disabled once every group is
open; collapse all is disabled once every group is closed, so the
control always reflects what's actionable. Individual group toggles are
unchanged. Removes the auto-expand mode in favour of directly driving
the expanded-groups set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ma9T44AwqyiidS5XrwcvVL
…ting

Module (and collapsed-group) tiles no longer ellipsize their labels —
the whole point of a node is to say what the module is. The layout now
computes one uniform tile size per report that fits the longest label:
labels are monospace, so width follows deterministically from character
count (no canvas measuring, works in tests). A label too long for the
max width wraps onto extra lines and every tile grows to match, keeping
the pack uniform. Tile width/height thread through the packing helpers
via a cached ModuleMetrics; the label CSS switches from truncate to
break-words.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ma9T44AwqyiidS5XrwcvVL
Remove the "seeds"/"core"/"leaves" text from the expanded group
container. The members stay sorted into those bands and a thin hairline
still delineates them, just without the labels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ma9T44AwqyiidS5XrwcvVL
Hovering a collapsed group node now previews its connectivity, the way
selecting a module does: the group's boundary edges (those crossing into
or out of the group) light up routed to the group node, the group and
the modules it connects to highlight, and everything else dims. Internal
edges stay hidden since a collapsed group draws them inside itself. The
preview is transient — it overrides the current selection only while the
pointer is on the group — via a new getGroupHoverSelection and hovered
group state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ma9T44AwqyiidS5XrwcvVL
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants