Skip to content

Commit 47e2469

Browse files
committed
refactor(resources): one canonical view per resource, mounted on source/grants/host
1 parent 33fe043 commit 47e2469

623 files changed

Lines changed: 16427 additions & 9149 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/rules/canon.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Canon — The 7 Canonical Concerns
2+
3+
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.
4+
5+
## 1. Next.js best practices
6+
7+
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`).
8+
9+
- Full rules: `apps/sim/app/(landing)/CLAUDE.md` (landing), `.claude/rules/sim-architecture.md` (server boundary)
10+
- **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.
11+
12+
## 2. SEO / GEO (landing only)
13+
14+
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`.
15+
16+
- Full rules: `.claude/rules/landing-seo-geo.md`, `apps/sim/app/(landing)/CLAUDE.md`
17+
- **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.
18+
19+
## 3. Feature file structure
20+
21+
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/`.
22+
23+
- Full rules: `apps/sim/app/(landing)/CLAUDE.md` "Structure", `.claude/rules/sim-architecture.md`
24+
- **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.
25+
26+
## 4. EMCN components only (platform)
27+
28+
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).
29+
30+
- Full rules: `.claude/rules/emcn-components.md` (authoring), `.claude/rules/sim-styling.md` (consumer)
31+
- **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`.
32+
33+
## 5. No ad-hoc animations or colors
34+
35+
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.
36+
37+
- Full rules: `.claude/rules/sim-styling.md` (tokens), `apps/sim/app/(landing)/CLAUDE.md` (motion)
38+
- **Gap**: the positive rule ("declare custom keyframes/tokens HERE and nowhere else") is unwritten — only the prohibition exists.
39+
40+
## 6. State placement (useState / Zustand / React Query / URL)
41+
42+
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.
43+
44+
- Full rules: `.claude/rules/sim-url-state.md` (the canonical 4-way table), `.claude/rules/sim-queries.md`, `.claude/rules/sim-stores.md`
45+
46+
## 7. Meta — authoring skills, rules, and CLAUDE.md files
47+
48+
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.
49+
50+
- **Gap**: no authoring guide exists — rule/skill conventions are learned by imitating existing files. Needs a short `.claude/rules/meta-authoring.md`.
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
---
2+
paths:
3+
- "apps/sim/resources/**/*.ts"
4+
- "apps/sim/components/resources/**/*.ts"
5+
- "apps/sim/components/resources/**/*.tsx"
6+
- "apps/sim/app/workspace/[workspaceId]/**/*.tsx"
7+
- "apps/sim/app/f/**/*.tsx"
8+
- "apps/sim/app/i/**/*.tsx"
9+
- "apps/sim/app/(interfaces)/**/*.tsx"
10+
- "apps/sim/hooks/queries/workspace-files.ts"
11+
---
12+
13+
# Resource Views
14+
15+
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 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.
16+
17+
**One view per resource. Consumers construct the axes and mount it. They never wrap it.**
18+
19+
Enforced by `bun run check:resources` (strict CI gate: `bun run check:resources:strict`), which is `scripts/check-resource-views.ts`.
20+
21+
## The three axes
22+
23+
`apps/sim/resources/**` is pure TypeScript — no React, no `'use client'` — because `app/i/[token]/page.tsx` builds a share source during SSR.
24+
25+
| Axis | Type | Replaces |
26+
| --- | --- | --- |
27+
| `source` | `WorkspaceSource<K> \| ShareSource<K>`, discriminated on `via` | `workspaceId`, `token`, `contentSource`, `isPublic`, `isShared` |
28+
| `grants` | `{ write; run; manage; settled }` | `canEdit`, `canRun`, `canAdmin`, `canDelete`, `disableEdit/Insert/Delete` |
29+
| `host` | `'page' \| 'panel' \| 'public'` | `embedded`, `isEmbedded`, `compact`, `minimal` |
30+
31+
There is no fourth axis. Agent streaming is **one optional prop on `FileView`** (`streaming?: FileViewStreaming`), because only files stream.
32+
33+
`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` (`table`, `knowledge`, `log`) structurally cannot construct a share source at all: "no public surface" is a compile-time fact.
34+
35+
```
36+
apps/sim/resources/ # kinds.ts · source.ts · grants.ts · host.ts — pure TS
37+
apps/sim/components/resources/<unit>/ # 'use client' — THE view, one per resource
38+
```
39+
40+
Every unit has the same layout, so moving between them costs nothing:
41+
42+
```
43+
<unit>/
44+
├── <unit>.tsx # THE view — what consumers mount
45+
├── index.ts # the barrel; the only entry point consumers use
46+
├── components/<child>/ # <child>.tsx + index.ts, one folder per child
47+
├── hooks/<name>.ts # optional; tests colocated
48+
├── utils/<name>.ts # pure helpers; tests colocated
49+
└── types.ts # optional; shared types the whole unit reads
50+
```
51+
52+
A barrel may also export a **child** — but only one a surface outside the unit
53+
genuinely renders against the same data, where forking it would be the drift the
54+
unit exists to prevent (`knowledge-view` exports `ActionBar`, `BaseTagsModal` and
55+
`DocumentTagsModal` for the knowledge list page and the document detail route).
56+
Exporting a child to save an import hop is not that; the child stays private and
57+
the consumer mounts the view.
58+
59+
Imports **inside** a unit are absolute (`@/components/resources/<unit>/...`) like
60+
everywhere else in the app. The one exception is a folder's own `index.ts`
61+
naming its siblings, which stays relative (`./<child>`) — it is describing its
62+
own directory, not reaching across the app.
63+
64+
All four kinds have a canonical view: `FileView`, `TableView`, `LogView`,
65+
`KnowledgeView`. A view reads **and** writes — `FileView` edits, `TableView`
66+
edits — gated on `grants.write`. "The view is read-only and the shell writes"
67+
was a staging point during the migration, never a rule.
68+
69+
A kind with no canonical view would simply be **absent** from the view list in `CANONICAL_UNITS` — that is the correct state for an unmigrated kind, not a flag, a shim, or a placeholder entry. Every kind has one today, so the list is full.
70+
71+
## Consume: construct the axes, then mount
72+
73+
That is the whole job. Same component, same props; only the constructed values differ.
74+
75+
```typescript
76+
// app/f/[token]/public-file-view.tsx — anonymous share
77+
const source = useMemo(
78+
() => shareSource({ kind: 'file', token, grantId: token, seed: { name, type, size, version } }),
79+
[token, name, type, size, version]
80+
)
81+
return <FileView source={source} grants={grantsForShare('file')} host='public' readOnly />
82+
```
83+
84+
```typescript
85+
// .../mothership-view/.../resource-content.tsx — panel, same view
86+
const source = useMemo(
87+
() => workspaceSource({ kind: 'file', workspaceId, resourceId: file.id }),
88+
[workspaceId, file.id]
89+
)
90+
const grants = useMemo(() => grantsFromPermissions(permissions), [permissions])
91+
return <FileView source={source} grants={grants} host='panel' streaming={streaming} />
92+
```
93+
94+
- Import from the **unit barrel** (`@/components/resources/file-view`), never a file inside it.
95+
- 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.
96+
- Links belong on the source too (`source.hrefFor(link)`), which returns `null` in share scope so nobody hand-builds `/workspace/${token}/…`. Three destinations: `{ to: 'self' }`, `{ to: 'resource', kind, id }`, and `{ to: 'list' }` for the index route the kind lives undera breadcrumb root, or where to go after the resource being shown is deleted.
97+
- `grants.settled` says whether `write`/`run`/`manage` are final. A resolving membership and a denied one produce identical booleans, so a surface that renders an affordance disabled while permissions loador fires a one-shot effectmust check it rather than reading `write === false` as a decision.
98+
- `host` decides chrome and URL ownership. `hostOwnsUrl(host)` is the single place the "embedded views do not write nuqs keys" rule lives.
99+
100+
## Never do this
101+
102+
**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`).
103+
104+
```typescript
105+
// ✗ Bad — adds a name, a file, and an import hop; adds no behavior.
106+
export function EmbeddedFilePanel({ source, grants, host }: EmbeddedFilePanelProps) {
107+
return <FileView source={source} grants={grants} host={host} />
108+
}
109+
110+
// ✓ Good — the consumer constructs the axes and mounts the view itself.
111+
const source = workspaceSource({ kind: 'file', workspaceId, resourceId })
112+
return <FileView source={source} grants={grants} host='panel' />
113+
```
114+
115+
**Never add a fourth spelling.** If the view cannot express what you need, change `source` / `grants` / `host`one place, every consumeror collapse the need into an existing optional object (`streaming`, `editing`). Do not add a loose prop.
116+
117+
```typescript
118+
// ✗ Bad — three axes, spelled four wrong ways.
119+
<FileView workspaceId={id} canEdit embedded streamingContent={text} isAgentEditing />
120+
121+
// ✓ Good
122+
<FileView source={source} grants={grants} host='panel' streaming={{ content: text, isAgentEditing }} />
123+
```
124+
125+
**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 windowingevery one of which the real view already handles.
126+
127+
**Never reach past the barrel.**
128+
129+
```typescript
130+
// ✗ Bad — binds you to the unit's private layout
131+
import { resolveFileCategory } from '@/components/resources/file-view/file-category'
132+
133+
// ✓ Good
134+
import { resolveFileCategory } from '@/components/resources/file-view'
135+
```
136+
137+
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.
138+
139+
**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**.
140+
141+
**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.
142+
143+
**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.
144+
145+
## Escape hatch
146+
147+
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):
148+
149+
```typescript
150+
// boundary-resource-wrapper: <reason>
151+
// boundary-resource-internal: <reason>
152+
// boundary-resource-tree: <reason>
153+
// boundary-resource-prop: <reason>
154+
```
155+
156+
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.
157+
158+
## Checklist before you add a component near a resource
159+
160+
1. Does a canonical view already exist for this kind? Mount it.
161+
2. Does it exist but lack a seam? Add the seam in the unit and thread itdo not fork the UI.
162+
3. Is your new component only forwarding props into a view? Delete it; mount the view at the call site.
163+
4. Are you about to write `embedded`, `canEdit`, `canRun`, `isPublic`, `token` or `workspaceId` on a view? Map it to `source` / `grants` / `host`.
164+
5. Run `bun run check:resources`. The success metric is **consumers per view going up and component count going down**.

0 commit comments

Comments
 (0)