Skip to content
Closed
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "vite",
"runtimeExecutable": "npx",
"runtimeArgs": ["vite", "--port", "3000"],
"port": 3000
},
{
"name": "netlify",
"runtimeExecutable": "npx",
"runtimeArgs": ["netlify", "dev"],
"port": 8888
}
]
}
2 changes: 2 additions & 0 deletions .github/workflows/dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ jobs:
${{ runner.os }}-
- name: install
run: npm ci
- name: lint
run: npm run lint
- name: test
run: npm test
- name: build
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,5 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*

shared/dist
shared/dist
design_handoff*
96 changes: 96 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

Structure (https://structure.codes) is an interactive web tool for designing and exploring repository directory structures. The user pastes a GitHub repo URL or picks a community template; the app renders the file tree two ways at once — as editable ASCII tree text (Monaco editor) and as a pannable/zoomable node graph (SVG) — and lets them filter by depth, hide files/dot-entries, then copy or download the result as a `.tree` file.

It is a React SPA served by Netlify, with a handful of serverless functions acting as a CORS/User-Agent proxy in front of the GitHub API and a remote templates repo. There is no database and no app-owned backend state.

## Commands

```sh
npm start # netlify dev — runs Vite (port 3000) proxied on :8888 with the /api functions
npm run build # tsc type-check then vite build -> dist/
npm run lint # eslint . (flat config in eslint.config.mjs)
npm test # vitest run (all tests, one pass)
npm run test:watch # vitest in watch mode
npm run test:functions # vitest run netlify — only the serverless-function tests
```

Run a single test file or test by name:

```sh
npx vitest run src/app/HomeView/Dropdown/__tests__/Dropdown.test.tsx
npx vitest run -t "returns template names"
```

Always run the app with `npm start` (`netlify dev`), **not** bare `vite` — the `/api/*` routes are Netlify functions and won't exist under plain Vite. The functions self-register their routes via the exported `config.path` in each file (e.g. `/api/github`), so there are no redirect rules wiring them up.

## Test environment

Vitest runs two environments from one config (`vite.config.mts`):
- `src/**` → **jsdom**, given a real origin (`http://localhost:3000/`) so the app's relative `fetch("/api/...")` calls resolve to absolute URLs that **msw** can intercept.
- `netlify/**` → **node** (matched via `environmentMatchGlobs`). Function tests stub `global.fetch` directly rather than using msw.

`src/setupTests.ts` is the shared setup file.

## Architecture

### Shared tree model and the two-pane bridge

The whole app revolves around one data type, `TreeType` (from the external `@structure-codes/utils` package): a recursive `{ _index, name, children }` node. The package also provides the two canonical converters used everywhere:
- `treeStringToJson(str)` — ASCII tree text → `TreeType[]`
- `treeJsonToString({ tree, tabChar, options })` — `TreeType[]` → ASCII text, applying the visibility `options` (depth limit, hideFiles, hideDots)

State lives in **Jotai** atoms in [src/store.ts](src/store.ts) (`useAtom`/`useAtomValue`/`useSetAtom`; the app is wrapped in a Jotai `<Provider>` in [main.tsx](src/app/main.tsx)):
- `treeAtom` — the current `TreeType[]` (the source of truth for structure)
- `settingsAtom` — `{ depth, hideFiles, hideDots }` visibility filters
- `baseTreeAtom` — a string identifying where the current tree came from (a GitHub URL or a template name); used to derive repo name/link and to reset the graph on source change
- `hoveredNodeAtom` / `selectedNodeAtom` — **transient, non-persisted** cross-pane link state holding a node's stable path id

The two panes must agree on *exactly* which nodes are visible. This is the central invariant: the filtering predicates in [src/app/HomeView/ModelPanel/layout.ts](src/app/HomeView/ModelPanel/layout.ts) (`isHidden`, `looksLikeFile`, the `SPECIAL_FILES` set) are hand-mirrored from `treeJsonToString`'s behavior so the graph and the Monaco text never disagree. **If you change visibility logic, change it in both places or the panes desync.**

Cross-pane hover/select linking works through stable path ids (e.g. `__root__/src/app`):
- `layout.ts:toRawTree` assigns each node an id from its path; `codeLineIds` produces the ordered id list mirroring `treeJsonToString`'s pre-order line output, so **editor line N ↔ ids[N-1]**.
- [CodePanel](src/app/HomeView/CodePanel/index.tsx) maps Monaco mouse events → line → id → sets the hover/select atoms, and conversely renders Monaco line decorations from those atoms.
- [ModelPanel](src/app/HomeView/ModelPanel/index.tsx) sets the same atoms from SVG node events, highlights the hovered node's lineage to the root, and recenters on selection.

So hovering a node in either pane lights up the matching row/node in the other, and clicking recenters the graph.

### The layout pipeline (ModelPanel)

[layout.ts](src/app/HomeView/ModelPanel/layout.ts) is a pure pipeline, ported from a design prototype and decoupled from React:

```
TreeType[] --toRawTree--> RawNode --buildVisibleTree--> VisNode --layoutTree--> { nodes, links, bbox } --linkPath--> SVG path
```

`buildVisibleTree` applies filters + collapse state; `layoutTree` does naive leaf-packing positioning for three layout kinds (`tree-h` default, `tree-v`, `radial`). ModelPanel memoizes this pipeline aggressively so pan/zoom/hover re-renders (which only touch transform + atoms) don't recompute geometry. Above ~600 visible nodes it drops the per-node glow filter to stay responsive (`heavy` flag). Pan/zoom is hand-rolled on an SVG `<g transform>` (pointer events + a non-passive wheel listener), not a library.

### CodePanel (Monaco)

[CodePanel](src/app/HomeView/CodePanel/index.tsx) registers a custom `"tree"` Monaco language ([customLang.ts](src/app/HomeView/CodePanel/customLang.ts)) and theme, and a folding-range provider derived from the tree. The editor is currently **read-only**: it renders `treeJsonToString(treeState)` and reflects atom-driven hover/select decorations. There is a large `handleEditorChange` function for live tree-prefix auto-formatting that is **intentionally disabled** (the `onDidChangeModelContent` wiring is commented out) — it's WIP, not dead code; leave it unless explicitly working on edit support. The branch-glyph constants/helpers live in [treeHelper.ts](src/app/HomeView/CodePanel/treeHelper.ts) (`│`, `├──`, `└──`).

### Data loading (Dropdown) and serverless functions

[Dropdown](src/app/HomeView/Dropdown/index.tsx) is the only place that loads trees. It reads the source from the URL on mount (deep-linkable: `/template/:template`, and `/template/github?owner=&repo=&branch=`), fetches via **`@tanstack/react-query`** (v5; query keys are arrays, e.g. `["templatesData"]`, and `useQuery` uses the object form), and writes `treeAtom` + `baseTreeAtom`. ModelPanel reuses the same query key (`["templatesData"]`) to resolve a template's origin GitHub link from the manifest.

The three Netlify functions in [netlify/functions/](netlify/functions/) are thin proxies (each adds a `User-Agent`, which GitHub requires, and handles errors):
- `github.ts` (`POST /api/github`) — fetches a repo's recursive git tree from the GitHub API, tries the requested branch then falls back `main`→`master`, and converts to `TreeType[]` via `netlify/lib/tree.ts:githubToTree`.
- `templates.ts` (`GET /api/templates`) — lists templates from the remote `structure-templates` repo.
- `template.ts` (`GET /api/template/:template`) — fetches one template's `.tree` text.

Templates and `.tree` files are **not** in this repo — they live in `structure-codes/structure-templates` on GitHub and are fetched at runtime.

## Conventions

- **Routing/dev:** `netlify dev` on `:8888` is the real entry point; Vite is the proxied framework server.
- **Component styles: CSS Modules.** Each component has a sibling `style.module.css` imported as `import classes from "./style.module.css"` (default import named `classes`, so call sites stay `classes.foo`). All values come from the token CSS vars — no hardcoded colors/fonts/dimensions. The component library is **MUI v5+ (`@mui/material`, emotion engine)**; there are no `makeStyles`/`useStyles` `style.ts` files. **Gotcha:** MUI's internal classes (`.MuiOutlinedInput-root`, `.MuiSlider-thumb`, …) are global, so wrap them in `:global(...)`. [main.tsx](src/app/main.tsx) wraps the app in `<StyledEngineProvider injectFirst>` so emotion injects its styles *first* and the CSS Modules win the cascade on order; as a belt-and-suspenders specificity margin, overrides applied to the *same element* as a MUI base class also double the local class (`.button.button`). Plain `.css` (non-module) is still used for the viz/Monaco internals ([tree.css](src/app/HomeView/ModelPanel/tree.css), [codePanel.css](src/app/HomeView/CodePanel/codePanel.css)) and the global utilities in [index.css](src/app/index.css).
- **Design tokens:** the color scale is authored once, in oklch, in [src/app/tokens.ts](src/app/tokens.ts). `applyTokens()` (called in `main.tsx` before render) injects the colors, `--accent`, and the font vars (`--font-ui`, `--font-mono`) onto `:root`; the `tokens` hex mirror and the `FONT_*` constants are exported for the JS-only consumers that can't read CSS vars / parse `oklch()` — the MUI theme ([theme.ts](src/app/theme.ts)) and Monaco ([customLang.ts](src/app/HomeView/CodePanel/customLang.ts), CodePanel editor options). `--accent` is the one accent definition (also feeds `depthColor.ts`). The hex mirror is *derived* via [oklch.ts](src/app/oklch.ts) — don't reintroduce a hand-maintained hex or font-stack table. Structural scales (`--radius`, `--radius-lg`, `--dur-fast`, `--dur`, `--space-1..4`) and the reusable `.glass` overlay utility live in [index.css](src/app/index.css); use them rather than hardcoding values.
- **Token guardrail:** component styles under `src/app` must use the `--token` vars, not raw colors. `npm run lint` (and CI) runs [scripts/check-tokens.mjs](scripts/check-tokens.mjs), which fails on any hex/`oklch()` literal outside the allowlisted token/bridge files (`tokens.ts`, `oklch.ts`, `theme.ts`, `index.css`, `customLang.ts`, `depthColor.ts`). Add a new literal home to that script's `ALLOW` set only if a JS bridge genuinely can't read a CSS var.
- **Facelift status:** all phases complete. The MUI v4→v5+ upgrade landed (`@mui/material` + `@emotion/*`, `palette.mode`, Autocomplete imported from core, `slotProps.input` replacing the removed `InputProps`, no `.MuiButton-label` wrapper). MUI/emotion are **inlined** in [vite.config.mts](vite.config.mts) so jsdom can transform their native ESM (a leftover guard from when React 17 didn't expose `react/jsx-runtime`; harmless to keep).
- **State + React stack:** state is **Jotai** (migrated off Recoil). The app is on **React 19** (`createRoot` in [main.tsx](src/app/main.tsx)). Data fetching is **`@tanstack/react-query` v5** (migrated off the unmaintained `react-query` v3). Clipboard uses the native `navigator.clipboard` (the old `react-copy-to-clipboard` dep was dropped). A batch of unused deps (`react-d3-tree`, `react-flow-renderer`, `react-organizational-chart`, `react-syntax-highlighter`, `prismjs`) was removed. Tests use `@testing-library/react` v16 (with an explicit `@testing-library/dom` peer).
- **Lint:** flat ESLint config. `@typescript-eslint/no-explicit-any` is **off** by design (API payloads + MUI handlers lean on `any`); unused vars are an error but `_`-prefixed names are ignored. The React Compiler hook ruleset is intentionally not enabled — only classic rules-of-hooks + exhaustive-deps (as a warning).
- React 19, `react-jsx` runtime, TS `strict`, `noEmit` (Vite/esbuild does the transpile; `tsc` is type-check only).
41 changes: 41 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";

export default tseslint.config(
{
ignores: ["dist", "node_modules", ".netlify", "public"],
},
{
files: ["**/*.{ts,tsx}"],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2022,
globals: { ...globals.browser, ...globals.node },
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
// Classic rules of hooks; the React Compiler rule set in v7's
// `recommended` is too noisy for this codebase.
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
// Catching unused references is the primary reason we lint here.
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
// This codebase leans on `any` in a number of intentional spots
// (API payloads, MUI event handlers); don't fail the build on it.
"@typescript-eslint/no-explicit-any": "off",
},
}
);
9 changes: 8 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
content="An interactive design tool to help developers structure their code"
/>
<link rel="apple-touch-icon" href="/logo192.png" />
<!-- IBM Plex Sans (UI) + IBM Plex Mono (code panel, node labels, numeric values) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
Expand All @@ -29,7 +36,7 @@
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script type="module" src="/src/app/main.tsx"></script>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
Expand Down
7 changes: 4 additions & 3 deletions netlify/functions/__tests__/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,14 @@ afterEach(() => {
});

describe("GET /api/templates", () => {
it("returns template names with the .tree suffix stripped", async () => {
it("returns template names (suffix stripped) paired with their source url", async () => {
global.fetch = vi.fn().mockResolvedValue(ok({ json: templateJson })) as unknown as typeof fetch;
const res = await templates();
expect(res.headers.get("content-type")).toMatch(/json/);
const body = await res.json();
expect(body).toContain("react-boilerplate");
expect(body.every((name: string) => !name.endsWith(".tree"))).toBe(true);
expect(body.map((t: { name: string }) => t.name)).toContain("react-boilerplate");
expect(body.every((t: { name: string }) => !t.name.endsWith(".tree"))).toBe(true);
expect(body.every((t: { url: string }) => typeof t.url === "string")).toBe(true);
});

it("sends a User-Agent (GitHub rejects requests without one)", async () => {
Expand Down
5 changes: 4 additions & 1 deletion netlify/functions/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ export default async (): Promise<Response> => {
const res = await fetch(templatesUrl, { headers: { "User-Agent": USER_AGENT } });
if (!res.ok) throw new Error(`Request failed with status ${res.status}`);
const data: ITemplates[] = await res.json();
const parsed: string[] = data.map((template) => template.name.replace(/\.tree$/, ""));
const parsed = data.map((template) => ({
name: template.name.replace(/\.tree$/, ""),
url: template.url,
}));
return json(parsed);
} catch (e) {
const errMsg = getErrorMessage(e);
Expand Down
2 changes: 1 addition & 1 deletion netlify/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Node",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"noImplicitAny": false,
Expand Down
Loading
Loading