Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
001cbb4
refactor(resources): one canonical view per resource, mounted on sour…
waleedlatif1 Aug 5, 2026
c013365
fix(share): keep the share CTA's post text brand-aware
waleedlatif1 Aug 5, 2026
4da3df5
fix(file-view): correct the data-table stylesheet path
waleedlatif1 Aug 5, 2026
006e9a7
refactor(resources): migrate log and knowledge onto the axes; collaps…
waleedlatif1 Aug 7, 2026
a4984be
Merge origin/staging into improvement/resource-views-final
waleedlatif1 Aug 7, 2026
89d7544
fix(merge): repair three regressions the staging merge surfaced
waleedlatif1 Aug 7, 2026
3b09102
chore(ci): stop running check:resources twice per audit pass
waleedlatif1 Aug 7, 2026
52e43bb
refactor(resources): close the two route-tree edges the migration cre…
waleedlatif1 Aug 7, 2026
9a96054
fix(uploads): commit local objects atomically, so "it exists" means "…
waleedlatif1 Aug 7, 2026
5a1af8f
fix(files, chat): contain the media work's blast radius
waleedlatif1 Aug 7, 2026
285e035
refactor(resources): give table-view the layout the other three units…
waleedlatif1 Aug 7, 2026
45e8671
chore(resources): delete the code this branch left with no callers
waleedlatif1 Aug 7, 2026
17de4bb
refactor(resources): extend the axes for what every kind actually nee…
waleedlatif1 Aug 7, 2026
d998f66
refactor(tables): move the shared dependencies out of the route tree
waleedlatif1 Aug 8, 2026
91383e8
Merge origin/staging into improvement/resource-views-final
waleedlatif1 Aug 8, 2026
bdf61bb
Merge branch 'improvement/resource-views-final' into refactor/resourc…
waleedlatif1 Aug 8, 2026
db1b08f
refactor(tables): take the table's address as props, not route params
waleedlatif1 Aug 8, 2026
fdb037f
fix(tables): stop the embedded table writing sort and view into its h…
waleedlatif1 Aug 8, 2026
32992f3
refactor(tables): take capability from grants, not the permission con…
waleedlatif1 Aug 8, 2026
f3b47ee
refactor(tables): navigate through onNavigate and the axis, not a router
waleedlatif1 Aug 8, 2026
c7ad09d
fix(tables): mount LogView directly for the execution slideout
waleedlatif1 Aug 8, 2026
901ace8
refactor(tables): TableView is a canonical resource view
waleedlatif1 Aug 8, 2026
d8cb711
docs(resources): all four kinds have a canonical view
waleedlatif1 Aug 8, 2026
f3ec1d4
refactor(tables): match TableViewProps to the shape the other three u…
waleedlatif1 Aug 8, 2026
c2f04e9
refactor(knowledge): sever the shell's route context, in place
waleedlatif1 Aug 8, 2026
904bac7
refactor(knowledge): make the shell the canonical view
waleedlatif1 Aug 8, 2026
90c7123
Merge origin/staging into refactor/resource-views-tables
waleedlatif1 Aug 8, 2026
354c6df
Merge remote-tracking branch 'origin/staging' into refactor/resource-…
waleedlatif1 Aug 8, 2026
ce234f4
fix(log-view): keep the tab local when the host wired no setter
waleedlatif1 Aug 8, 2026
c0dc91e
fix(share): close two holes in the public content route
waleedlatif1 Aug 8, 2026
d512856
fix(share): declare the resolved media type, not the stored one
waleedlatif1 Aug 8, 2026
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`.
164 changes: 164 additions & 0 deletions .claude/rules/sim-resource-views.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
---
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 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; run; manage; settled }` | `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` (`table`, `knowledge`, `log`) 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
```

Every unit has the same layout, so moving between them costs nothing:

```
<unit>/
├── <unit>.tsx # THE view — what consumers mount
├── index.ts # the barrel; the only entry point consumers use
├── components/<child>/ # <child>.tsx + index.ts, one folder per child
├── hooks/<name>.ts # optional; tests colocated
├── utils/<name>.ts # pure helpers; tests colocated
└── types.ts # optional; shared types the whole unit reads
```

A barrel may also export a **child** — but only one a surface outside the unit
genuinely renders against the same data, where forking it would be the drift the
unit exists to prevent (`knowledge-view` exports `ActionBar`, `BaseTagsModal` and
`DocumentTagsModal` for the knowledge list page and the document detail route).
Exporting a child to save an import hop is not that; the child stays private and
the consumer mounts the view.

Imports **inside** a unit are absolute (`@/components/resources/<unit>/...`) like
everywhere else in the app. The one exception is a folder's own `index.ts`
naming its siblings, which stays relative (`./<child>`) — it is describing its
own directory, not reaching across the app.

All four kinds have a canonical view: `FileView`, `TableView`, `LogView`,
`KnowledgeView`. A view reads **and** writes — `FileView` edits, `TableView`
edits — gated on `grants.write`. "The view is read-only and the shell writes"
was a staging point during the migration, never a rule.

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.

## 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}/…`. Three destinations: `{ to: 'self' }`, `{ to: 'resource', kind, id }`, and `{ to: 'list' }` for the index route the kind lives under — a breadcrumb root, or where to go after the resource being shown is deleted.
- `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 load — or fires a one-shot effect — must check it rather than reading `write === false` as a decision.
- `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**.
Loading
Loading