Skip to content
Open
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
50 changes: 50 additions & 0 deletions .claude/rules/canon.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Canon — The 7 Canonical Concerns

The non-negotiable conventions every change is measured against. This file is the index — the full rules live in the referenced files. When a canon rule and any other guidance conflict, canon wins. Known gaps are flagged so we close them deliberately instead of re-discovering them.

## 1. Next.js best practices

Server Components by default; `'use client'` only on the smallest leaf. `page.tsx` owns metadata. `next/image` with `priority` on the LCP element; `next/dynamic` for below-fold. Client refs are never called server-side (enforced by `scripts/check-client-boundary-imports.ts`).

- Full rules: `apps/sim/app/(landing)/CLAUDE.md` (landing), `.claude/rules/sim-architecture.md` (server boundary)
- **Gap**: no general app-router conventions rule — when to add `error.tsx` / `loading.tsx` / `not-found.tsx`, `generateMetadata` vs static `metadata`, ISR/`revalidate`, Suspense/streaming. Landing doc covers landing only.

## 2. SEO / GEO (landing only)

One `<h1>` (Hero), strict heading hierarchy, `<section aria-labelledby>`, server-rendered navbar, JSON-LD, answer-first H2s, atomic extractable blocks, entity consistency ("Sim", never "the platform"), sr-only summaries, concrete numbers. Copy follows `.claude/rules/constitution.md`.

- Full rules: `.claude/rules/landing-seo-geo.md`, `apps/sim/app/(landing)/CLAUDE.md`
- **Gap**: docs reference a single `structured-data.tsx` but code is split into `site-structured-data/`, `home-structured-data/`, `json-ld/`. `sitemap.ts` / `robots.ts` / `manifest.ts` conventions (app root) are undocumented.

## 3. Feature file structure

Every feature dir: `feature.tsx` + `page.tsx` (+ `error.tsx`, `loading.tsx`, `search-params.ts` where applicable) + `utils/` (only for 2+ consumers — single-consumer helpers stay in `feature.tsx`) + `hooks/` + `components/`. Every component lives in its own kebab-case folder holding `<name>.tsx` + `index.ts` barrel; children nest under that folder's own `components/`, recursively. Never a bare `<name>.tsx` flat inside a `components/` directory. Reference implementation: `apps/sim/app/workspace/[workspaceId]/scheduled-tasks/`.

- Full rules: `apps/sim/app/(landing)/CLAUDE.md` "Structure", `.claude/rules/sim-architecture.md`
- **Gap**: the recursion + barrel rule is only fully written in the landing CLAUDE.md; `sim-architecture.md` shows a flatter sketch and omits `search-params.ts` / `error.tsx` / `loading.tsx` co-location.

## 4. EMCN components only (platform)

No custom buttons/inputs/menus — always the `@sim/emcn` chip-family equivalent. Components own their chrome; consumers pass props, never chrome via `className` (layout/sizing only).

- Full rules: `.claude/rules/emcn-components.md` (authoring), `.claude/rules/sim-styling.md` (consumer)
- **Gap**: stale paths — EMCN moved to `packages/emcn/` but `emcn-components.md` frontmatter still scopes `apps/sim/components/emcn/**`, and the landing CLAUDE.md still says import from `@/components/emcn`.

## 5. No ad-hoc animations or colors

Colors come from tokens in `apps/sim/app/_styles/globals.css` / `tailwind.config.ts` — never raw hex in components. Animations: prefer CSS, respect `prefers-reduced-motion`, no new keyframes outside the Tailwind config / scoped `.module.css`. Never touch global styles.

- Full rules: `.claude/rules/sim-styling.md` (tokens), `apps/sim/app/(landing)/CLAUDE.md` (motion)
- **Gap**: the positive rule ("declare custom keyframes/tokens HERE and nowhere else") is unwritten — only the prohibition exists.

## 6. State placement (useState / Zustand / React Query / URL)

One four-way decision: React Query = all server state · nuqs URL params = shareable view-state · Zustand = high-frequency, ephemeral, or socket-synced state · useState = purely local UI. Never `useState` + `fetch`; never store-synced-with-effects for view-state.

- Full rules: `.claude/rules/sim-url-state.md` (the canonical 4-way table), `.claude/rules/sim-queries.md`, `.claude/rules/sim-stores.md`

## 7. Meta — authoring skills, rules, and CLAUDE.md files

How we write the docs themselves: CLAUDE.md stays a lean index; detailed conventions go in `.claude/rules/*.md` with `paths:` frontmatter globs so they load only when matching files are touched; repeatable multi-step procedures become skills (`.claude/skills/` or `.claude/commands/`); one-off preferences go in memory, not the repo.

- **Gap**: no authoring guide exists — rule/skill conventions are learned by imitating existing files. Needs a short `.claude/rules/meta-authoring.md`.
134 changes: 134 additions & 0 deletions .claude/rules/sim-resource-views.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
---
paths:
- "apps/sim/resources/**/*.ts"
- "apps/sim/components/resources/**/*.ts"
- "apps/sim/components/resources/**/*.tsx"
- "apps/sim/app/workspace/[workspaceId]/**/*.tsx"
- "apps/sim/app/f/**/*.tsx"
- "apps/sim/app/i/**/*.tsx"
- "apps/sim/app/(interfaces)/**/*.tsx"
- "apps/sim/hooks/queries/workspace-files.ts"
---

# Resource Views

A **resource** is a thing a workspace holds that can also be shared: a file, a table, an interface, a knowledge base, a log, a scheduled task. A resource with a canonical view has **exactly one**, and every consumer mounts that one — the workspace route page, the mothership panel, an interface module, and the public share page.

**One view per resource. Consumers construct the axes and mount it. They never wrap it.**

Enforced by `bun run check:resources` (strict CI gate: `bun run check:resources:strict`), which is `scripts/check-resource-views.ts`.

## The three axes

`apps/sim/resources/**` is pure TypeScript — no React, no `'use client'` — because `app/i/[token]/page.tsx` builds a share source during SSR.

| Axis | Type | Replaces |
| --- | --- | --- |
| `source` | `WorkspaceSource<K> \| ShareSource<K>`, discriminated on `via` | `workspaceId`, `token`, `contentSource`, `isPublic`, `isShared` |
| `grants` | `{ write: boolean; run: boolean }` | `canEdit`, `canRun`, `canAdmin`, `canDelete`, `disableEdit/Insert/Delete` |
| `host` | `'page' \| 'panel' \| 'public'` | `embedded`, `isEmbedded`, `compact`, `minimal` |

There is no fourth axis. Agent streaming is **one optional prop on `FileView`** (`streaming?: FileViewStreaming`), because only files stream.

`ShareSource` declares `workspaceId?: never` and `resourceId?: never`, and `WorkspaceSource` declares `token?: never` and `seed?: never`. A share source **cannot** carry a workspace id — that is a compile error, not a convention. A kind whose seed is typed `never` (`knowledge`, `log`, `schedule`) structurally cannot construct a share source at all: "no public surface" is a compile-time fact.

```
apps/sim/resources/ # kinds.ts · source.ts · grants.ts · host.ts — pure TS
apps/sim/components/resources/<unit>/ # 'use client' — THE view, one per resource
```

A resource kind with no canonical view yet (`table`, `knowledge`, `log`, `schedule`) is simply **absent** from `CANONICAL_UNITS` in the check. That is the correct state for an unmigrated kind. Do not add a flag, a shim, or a placeholder entry for it.

## Consume: construct the axes, then mount

That is the whole job. Same component, same props; only the constructed values differ.

```typescript
// app/f/[token]/public-file-view.tsx — anonymous share
const source = useMemo(
() => shareSource({ kind: 'file', token, grantId: token, seed: { name, type, size, version } }),
[token, name, type, size, version]
)
return <FileView source={source} grants={grantsForShare('file')} host='public' readOnly />
```

```typescript
// .../mothership-view/.../resource-content.tsx — panel, same view
const source = useMemo(
() => workspaceSource({ kind: 'file', workspaceId, resourceId: file.id }),
[workspaceId, file.id]
)
const grants = useMemo(() => grantsFromPermissions(permissions), [permissions])
return <FileView source={source} grants={grants} host='panel' streaming={streaming} />
```

- Import from the **unit barrel** (`@/components/resources/file-view`), never a file inside it.
- Copy that differs between workspace and share belongs on the **source** (`source.unavailableCopy`), not in the view. A share must never say "workspace" — that is what stops the view becoming an existence oracle.
- Links belong on the source too (`source.hrefFor(link)`), which returns `null` in share scope so nobody hand-builds `/workspace/${token}/…`.
- `host` decides chrome and URL ownership. `hostOwnsUrl(host)` is the single place the "embedded views do not write nuqs keys" rule lives.

## Never do this

**Never wrap a view.** A component whose body is a canonical view with its own props forwarded in is a wrapper. `check:resources` fails on the first one (`wrapperMounts` is at `0`).

```typescript
// ✗ Bad — adds a name, a file, and an import hop; adds no behavior.
export function EmbeddedFilePanel({ source, grants, host }: EmbeddedFilePanelProps) {
return <FileView source={source} grants={grants} host={host} />
}

// ✓ Good — the consumer constructs the axes and mounts the view itself.
const source = workspaceSource({ kind: 'file', workspaceId, resourceId })
return <FileView source={source} grants={grants} host='panel' />
```

**Never add a fourth spelling.** If the view cannot express what you need, change `source` / `grants` / `host` — one place, every consumer — or collapse the need into an existing optional object (`streaming`, `editing`). Do not add a loose prop.

```typescript
// ✗ Bad — three axes, spelled four wrong ways.
<FileView workspaceId={id} canEdit embedded streamingContent={text} isAgentEditing />

// ✓ Good
<FileView source={source} grants={grants} host='panel' streaming={{ content: text, isAgentEditing }} />
```

**Never reimplement.** If a view has no seam for what you need, **add the seam**. A hand-rolled mini-table loses booleans, JSON, dates, links, resource chips, pinned columns and windowing — every one of which the real view already handles.

**Never reach past the barrel.**

```typescript
// ✗ Bad — binds you to the unit's private layout
import { resolveFileCategory } from '@/components/resources/file-view/file-category'

// ✓ Good
import { resolveFileCategory } from '@/components/resources/file-view'
```

The one sanctioned exception is a `lazy()` code-split point, where routing through the barrel silently re-attaches the split chunk (`apps/sim` has no `sideEffects: false`). Those go in `INTERNAL_IMPORT_ALLOWLIST` in the check, keyed by importer **and** specifier.

**Never import the workspace route tree from an anonymous surface.** `app/f/**`, `app/(interfaces)/**`, `app/(shared)/**` and public API routes may not import `@/app/workspace/[workspaceId]/**`. Shared units live in `apps/sim/components/resources/**`. Nesting under a `[workspaceId]` segment is exactly why `workspaceId: string` once read as natural on a component anonymous visitors mounted with a **share token**.

**Never read route or permission context inside a unit.** No `useRouter`, `useParams`, `useSearchParams`, `usePathname`, `useQueryState(s)`, or `useUserPermissionsContext` under `apps/sim/components/resources/**`. Addressing is `source`, navigation targets are `source.hrefFor(link)`, capability is `grants`, URL ownership is `host`. A component that falls back to `useParams()` can only ever exist once per page.

**Never put `'use client'` in `apps/sim/resources/**`.** Next rewrites every export of a `'use client'` module into a client reference in the server bundle, so the Server Component that builds a share source would throw at runtime.

## Escape hatch

Four annotations, reason mandatory, on the line directly above the offending mount / import / attribute (up to three preceding comment lines of extra context are tolerated):

```typescript
// boundary-resource-wrapper: <reason>
// boundary-resource-internal: <reason>
// boundary-resource-tree: <reason>
// boundary-resource-prop: <reason>
```

An annotation with an empty reason is still a finding **and** trips `annotationsMissingReason`. Whole-file exceptions go through `INTERNAL_IMPORT_ALLOWLIST` / `CROSS_TREE_ALLOWLIST` in `scripts/check-resource-views.ts`, not per-line annotations.

## Checklist before you add a component near a resource

1. Does a canonical view already exist for this kind? Mount it.
2. Does it exist but lack a seam? Add the seam in the unit and thread it — do not fork the UI.
3. Is your new component only forwarding props into a view? Delete it; mount the view at the call site.
4. Are you about to write `embedded`, `canEdit`, `canRun`, `isPublic`, `token` or `workspaceId` on a view? Map it to `source` / `grants` / `host`.
5. Run `bun run check:resources`. The success metric is **consumers per view going up and component count going down**.
99 changes: 99 additions & 0 deletions .cursor/rules/sim-resource-views.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
---
description: Resource views — one view per resource, many consumers, no wrappers
globs: ["apps/sim/resources/**/*.ts", "apps/sim/components/resources/**/*.ts", "apps/sim/components/resources/**/*.tsx", "apps/sim/app/workspace/[workspaceId]/**/*.tsx", "apps/sim/app/f/**/*.tsx", "apps/sim/app/(interfaces)/**/*.tsx"]
---
# Resource Views

A **resource** is a thing a workspace holds that can also be shared: a file, a table, an interface, a knowledge base, a log, a scheduled task. A resource with a canonical view has **exactly one**, and every consumer mounts that one — the workspace page, the mothership panel, an interface module, the public share page.

**One view per resource. Consumers construct the axes and mount it. They never wrap it.**

Enforced by `bun run check:resources`; strict gate `bun run check:resources:strict`.

## Three axes, nothing else

| Axis | Type | Replaces |
| --- | --- | --- |
| `source` | `WorkspaceSource<K> \| ShareSource<K>`, discriminated on `via` | `workspaceId`, `token`, `contentSource`, `isPublic` |
| `grants` | `{ write: boolean; run: boolean }` | `canEdit`, `canRun`, `canAdmin`, `disableEdit/Insert/Delete` |
| `host` | `'page' \| 'panel' \| 'public'` | `embedded`, `isEmbedded`, `compact`, `minimal` |

There is no fourth axis. Agent streaming is one optional prop on `FileView` (`streaming?`), because only files stream.

`ShareSource` declares `workspaceId?: never`; `WorkspaceSource` declares `token?: never`. A share source cannot carry a workspace id — compile error, not convention.

```
apps/sim/resources/ # kinds/source/grants/host — pure TS, NO 'use client'
apps/sim/components/resources/<unit>/ # 'use client' — THE view, one per resource
```

A kind with no canonical view yet (`table`, `knowledge`, `log`, `schedule`) is simply absent from the check's `CANONICAL_UNITS`. Do not add a flag, shim, or placeholder for it.

## Consume: construct, then mount

```typescript
// Anonymous share
const source = shareSource({ kind: 'file', token, grantId: token, seed })
return <FileView source={source} grants={grantsForShare('file')} host='public' readOnly />

// Panel — same view, same props
const source = workspaceSource({ kind: 'file', workspaceId, resourceId: file.id })
return <FileView source={source} grants={grantsFromPermissions(permissions)} host='panel' />
```

Import from the unit barrel (`@/components/resources/file-view`), never a file inside it. Scope-dependent copy lives on `source.unavailableCopy`; links on `source.hrefFor(link)` (which returns `null` in share scope). `hostOwnsUrl(host)` is the one place the "embedded views do not write nuqs keys" rule lives.

## Never

```typescript
// ✗ Wrapper — adds a name, a file, an import hop; adds no behavior. Fails check:resources.
export function EmbeddedFilePanel({ source, grants, host }: EmbeddedFilePanelProps) {
return <FileView source={source} grants={grants} host={host} />
}

// ✓ The consumer constructs the axes and mounts the view itself
const source = workspaceSource({ kind: 'file', workspaceId, resourceId })
return <FileView source={source} grants={grants} host='panel' />
```

```typescript
// ✗ A fourth spelling for capability / chrome / address
<FileView workspaceId={id} canEdit embedded streamingContent={text} isAgentEditing />

// ✓
<FileView source={source} grants={grants} host='panel' streaming={{ content: text, isAgentEditing }} />
```

```typescript
// ✗ Reaching past the barrel
import { resolveFileCategory } from '@/components/resources/file-view/file-category'

// ✓
import { resolveFileCategory } from '@/components/resources/file-view'
```

- **Never reimplement** a resource's UI because the view has no seam — add the seam. A hand-rolled mini-table loses booleans, JSON, dates, links, chips, pinned columns and windowing.
- **Never import `@/app/workspace/[workspaceId]/**`** from `app/f/**`, `app/(interfaces)/**`, `app/(shared)/**`, or a public API route. Shared units live in `apps/sim/components/resources/**`.
- **Never** use `useRouter` / `useParams` / `useSearchParams` / `usePathname` / `useQueryState(s)` / `useUserPermissionsContext` inside `apps/sim/components/resources/**`. Addressing is `source`, navigation is `source.hrefFor`, capability is `grants`, URL ownership is `host`.
- **Never** put `'use client'` in `apps/sim/resources/**` — a Server Component builds a share source from it during SSR.

## Escape hatch

Reason mandatory, on the line directly above the offending mount / import / attribute:

```typescript
// boundary-resource-wrapper: <reason>
// boundary-resource-internal: <reason>
// boundary-resource-tree: <reason>
// boundary-resource-prop: <reason>
```

An empty reason is still a finding. Whole-file exceptions go in `INTERNAL_IMPORT_ALLOWLIST` / `CROSS_TREE_ALLOWLIST` in `scripts/check-resource-views.ts`. The only sanctioned deep import is a `lazy()` split point, where the barrel would re-attach the split chunk.

## Before adding a component near a resource

1. Does a canonical view exist for this kind? Mount it.
2. Exists but lacks a seam? Add the seam in the unit — do not fork.
3. Only forwarding props into a view? Delete it; mount the view at the call site.
4. About to write `embedded` / `canEdit` / `canRun` / `isPublic` / `token` / `workspaceId` on a view? Map it to `source` / `grants` / `host`.
5. Run `bun run check:resources`. Success is consumers-per-view up, component count down.
8 changes: 3 additions & 5 deletions .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ jobs:
- name: API contract boundary audit
run: bun run check:api-validation:strict

- name: Resource view boundary audit
run: bun run check:resources

- name: Desktop bridge contract audit
run: bun run check:desktop-bridge

Expand Down Expand Up @@ -181,11 +184,6 @@ jobs:
- name: Type-check realtime server
run: bunx turbo run type-check --filter=@sim/realtime

# cloud-review-tools.test.ts runs the real helper on the runner, which shells
# out to rg. Blacksmith's image ships it, GitHub's doesn't.
- name: Install ripgrep
run: command -v rg || (sudo apt-get update && sudo apt-get install -y ripgrep)

# Runs the setup CLI's Bun tests plus each workspace's Vitest suite,
# without `--coverage`. See the Codecov note below.
- name: Run tests
Expand Down
Loading
Loading