Skip to content
Merged
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
8 changes: 6 additions & 2 deletions dataweaver/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Run `pnpm lint` (and `pnpm build` for UI changes) before considering work done.
- **TypeScript** follows the [Google TypeScript Style Guide](https://google.github.io/styleguide/tsguide.html), enforced via Biome (`biome.json`).
- **CSS / SCSS** follow the [Google HTML/CSS Style Guide](https://google.github.io/styleguide/htmlcssguide.html): Stylelint for `.scss` (`stylelint.config.mjs`), Biome for plain `.css`.
- **File naming** — use `dash-case` for all Next.js routing within `apps/web/src/app` (route segments, `page.tsx`, dynamic params, etc.) and `snake_case` for every other file.
- **Category-first naming** — composed names lead with what the thing *is*, then what makes it specific: `card_chart` (not `chart_card`), `button_close`, `icon_arrow_right`. Applies to files, folders, component identifiers, and element-prefixed SCSS classes. See [`FRONTEND.md` §1.2](FRONTEND.md#12-naming--category-first).

## Frontend

Expand All @@ -49,11 +50,14 @@ elements compose primitives); `foundations` wrap the whole tree from the root.
`primitives/icons/*`.
- **`elements/`** — generic, reusable, presentational building blocks composed
from primitives (a button, a tabs control). Self-contained, no feature or
business logic, usable anywhere. _e.g._ `elements/button`.
business logic, usable anywhere. **Flat by default** (`elements/button.tsx` +
`button.module.scss` sit next to `card.tsx` + `card.module.scss`); promote to
a folder only when the element gains a sub-component used solely by it. See
[`FRONTEND.md` §1.1](FRONTEND.md#11-flat-vs-nested--avoid-early-nesting).
- **`scopes/`** — feature- or page-scoped compositions that assemble primitives
and elements into a specific view. A scope **owns its sub-components**: pieces
used only by that scope live in its folder, not in `elements/`. _e.g._
`scopes/page_home` and `scopes/tldraw`.
`scopes/page_home` and `scopes/atlas`.
- **`foundations/`** — app-level infrastructure and cross-cutting providers /
services that the rest of the tree depends on but that render little or no UI
of their own: context providers, motion / scroll providers, analytics, global
Expand Down
69 changes: 62 additions & 7 deletions dataweaver/FRONTEND.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ enforced via `biome.json` and `stylelint.config.mjs`.
| Component | Location |
|---|---|
| Primitive (wrapper over one platform / third-party concern) | `components/primitives/<name>.tsx`, or grouped by category — e.g. `components/primitives/icons/<name>.tsx` |
| Element (generic, reusable building block) | `components/elements/<name>/<name>.tsx` + `.module.scss` |
| Element (generic, reusable building block) | flat by default: `components/elements/<name>.tsx` + `<name>.module.scss`. Promote to a folder only when sub-components appear (see "Flat vs. nested" below). |
| Scope (feature- / page-scoped composition) | `components/scopes/<name>/<name>.tsx` |
| Scope-local subcomponent (used only by that scope) | nested in the scope folder, e.g. `components/scopes/tldraw/card/card.tsx` |
| Scope-local subcomponent (used only by that scope) | nested in the scope folder, e.g. `components/scopes/atlas/card/card.tsx` |
| Foundation (app-level provider / service / global embed) | `components/foundations/<name>.tsx`, mounted once near the root |

Layered low → high: **primitives → elements → scopes**, with **foundations**
Expand All @@ -46,6 +46,61 @@ reuse and concern (one platform concern → primitive; reusable presentational U
- Keep a component in the narrowest scope that owns it. A component used only
inside one scope lives in that scope's folder, not in `elements/`.

### 1.1 Flat vs. nested — avoid early nesting

Default to **flat**: a component is a pair of sibling files named after it,
sitting next to its peers.

```
elements/
button.tsx
button.module.scss
card.tsx
card.module.scss
```

**Promote to a folder only when the component gains a sub-component used solely
by it.** The original pair keeps its name; the sub-component lives alongside.

```
elements/
button/
button.tsx
button.module.scss
icon.tsx # used only by button
icon.module.scss
card.tsx
card.module.scss
```

A sub-component that becomes reused outside its parent gets promoted out to its
own flat pair in `elements/` (or up to a `primitive`, depending on concern).
Don't create a folder "in anticipation" of future sub-components — wait until
the second file actually exists. Same rule applies inside `scopes/`: a scope is
always a folder (it owns its view), but its sub-components stay flat inside
that folder until one of *them* grows a child of its own.

### 1.2 Naming — category first

**Lead the name with what the thing *is*, then what makes it specific.** A
card that holds a chart is `card_chart`, not `chart_card`; a card that holds
text is `card_text`; a button that closes is `button_close`; an icon of an
arrow is `icon_arrow_right`.

This trades a slightly less natural-sounding name for real DX wins:

- **Sorted directory listings group by category** — all `card_*` sit together,
all `button_*` sit together, all `icon_*` sit together. Browsing the folder
reads like an index of what's available.
- **Editor fuzzy-find narrows by category** — typing `card` surfaces every
card variant; you don't have to remember the modifier first.
- **Imports stay parallel** — `import { CardChart } from …; import { CardText }
from …;` line up visually instead of scattering by adjective.

Apply this everywhere a name is composed of a noun + modifier: file and folder
names, component identifiers (`CardChart`, not `ChartCard`), and the
element-prefixed SCSS classes in §3.2 (`.button-close`, `.icon-arrow-right`).

---

## 2. TypeScript
Expand Down Expand Up @@ -87,8 +142,7 @@ CSS Modules only (`*.module.scss`), imported as `import s from './x.module.scss'

`~/styles/includes` (breakpoint / helper / z-index mixins) is **auto-injected**
into every module via `next.config.ts` `additionalData` — do **not** re-`@use`
it. Only `@use` files that aren't part of includes (e.g.
`@use "~/styles/typography.module" as typography;`).
it.

### 3.1 Selectors & formatting

Expand All @@ -109,19 +163,20 @@ it. Only `@use` files that aren't part of includes (e.g.
`<h2 className={s.title}>{title}</h2>`. Otherwise use a `<noun>-container` or
an element-prefixed class.
- **Multiple buttons / icons → element-prefixed kebab**: `.button-close`,
`.icon-arrow-right` (read left-to-right: "a button that closes").
`.icon-arrow-right` (read left-to-right: "a button that closes"). Same
category-first rule as §1.2 — never `.close-button` / `.arrow-right-icon`.

### 3.3 Variants & state via `data-*`

Drive visual variants and boolean state through `data-*` attributes on the
container — never through className flags:

```tsx
<article className={s.container} data-state={state} data-has-footer={hasFooter}>
<article className={s.container} data-variant={variant} data-is-loading={isLoading}>
```

```scss
.container[data-state="selected"] .card { … }
.container[data-is-loading="true"] .card { … }
```

### 3.4 Design tokens
Expand Down
5 changes: 3 additions & 2 deletions dataweaver/apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,16 @@
"clsx": "^2.1.1",
"motion": "^12.38.0",
"next": "^16.2.6",
"react-dom": "^19.2.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"recharts": "^3.8.1",
"tldraw": "^5.0.1"
},
"devDependencies": {
"@package/tokens": "workspace:tokens",
"@types/node": "^25.9.1",
"@types/react-dom": "^19.2.3",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "^1.0.0",
"sass": "^1.97.3"
}
Expand Down
12 changes: 12 additions & 0 deletions dataweaver/apps/web/src/app/(atlas)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { ReactNode } from 'react';
import { AtlasProvider } from '~/components/scopes/atlas/atlas';

interface AtlasLayoutProps {
children: ReactNode;
}

const AtlasLayout = ({ children }: AtlasLayoutProps) => {
return <AtlasProvider>{children}</AtlasProvider>;
};

export default AtlasLayout;
16 changes: 14 additions & 2 deletions dataweaver/apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,25 @@ import '~/styles/core.scss';
import type { ReactNode } from 'react';
import { MotionProvider } from '~/components/foundations/motion_provider';

interface LayoutProps {
const FONT_URL =
'https://fonts.googleapis.com/css?family=Google+Sans:400,500,700&display=swap&lang=en';

interface RootLayoutProps {
children: ReactNode;
}

const RootLayout = ({ children }: LayoutProps) => {
const RootLayout = ({ children }: RootLayoutProps) => {
return (
<html lang="en">
<head>
<link
rel="preconnect"
href="https://fonts.gstatic.com/"
crossOrigin="anonymous"
/>
<link rel="preload" as="font" href={FONT_URL} crossOrigin="anonymous" />
<link href={FONT_URL} rel="stylesheet" />
</head>
<body>
<MotionProvider>
<main>{children}</main>
Expand Down
49 changes: 49 additions & 0 deletions dataweaver/apps/web/src/components/elements/button.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
.container {
display: flex;
gap: 2px;
align-items: center;
justify-content: center;
color: rgb(var(--color-button-content));
background: rgb(var(--color-button-base));

&[data-shape="pill"] {
height: 28px;
padding-inline: 12px 16px;
border-radius: 14px;
}

&[data-shape="square"] {
width: 40px;
height: 40px;
border-radius: 20px;
}

@include prefers-motion {
transition-timing-function: $ease-linear;
transition-duration: 0.2s;
transition-property: color, background;
}

// TODO: Implement disabled style
&[disabled] {
cursor: not-allowed;
opacity: 0.4;
}

&:not([disabled]) {
@include hover {
color: rgb(var(--color-button-content-hover));
background: rgb(var(--color-button-base-hover));
}
}
}

.icon {
flex-shrink: 0;
width: 24px;
height: 24px;
}

.children {
@include type-label;
}
65 changes: 65 additions & 0 deletions dataweaver/apps/web/src/components/elements/button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { ComponentPropsWithRef, ComponentType } from 'react';
import { mergeClassNames } from '~/functions/merge_class_names';
import { mergeStyles } from '~/functions/merge_styles';
import s from './button.module.scss';

interface WithIconOnly {
icon: ComponentType<ComponentPropsWithRef<'svg'>>;
'aria-label': string;
children?: never;
}
Comment thread
PauloMFJ marked this conversation as resolved.

interface WithChildrenAndIcon {
icon: ComponentType<ComponentPropsWithRef<'svg'>>;
children: React.ReactNode;
}

interface ColorScheme {
base: string;
'base-hover': string;
content: string;
'content-hover': string;
}

type ButtonProps = {
/** If left `undefined`, the button will use the default app color scheme. */
colorScheme?: ColorScheme;

/** @default false */
isDisabled?: boolean;
} & Omit<ComponentPropsWithRef<'button'>, 'disabled' | 'children'> &
(WithIconOnly | WithChildrenAndIcon);

export const Button = ({
icon: Icon,
children,
colorScheme,
isDisabled = false,
...rest
}: ButtonProps) => {
const hasChildren = Boolean(children);
const shape = hasChildren ? 'pill' : 'square';

return (
<button
type="button"
{...rest}
className={mergeClassNames(s.container, rest.className)}
data-shape={shape}
disabled={isDisabled}
style={mergeStyles(
colorScheme && {
'--color-button-base': colorScheme.base,
'--color-button-base-hover': colorScheme['base-hover'],
'--color-button-content': colorScheme.content,
'--color-button-content-hover': colorScheme['content-hover'],
},
rest.style,
)}
>
<Icon className={s.icon} />

{children && <span className={s.children}>{children}</span>}
</button>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
.container {
--actions-height: 48px;
--corner-size: 16px;
--border-thickness: 2px;

display: grid;
height: 100%;
}

.actions-container {
display: flex;
grid-area: 1 / 1;
gap: 6px;
align-items: center;
justify-content: center;
height: calc(var(--actions-height) + var(--corner-size));
padding-bottom: calc(var(--corner-size) - var(--border-thickness));
margin: var(--actions-height) 2px 0;
background: rgb(var(--color-card-base-selected));
border-radius: var(--corner-size) var(--corner-size) 0 0;
box-shadow:
0 1px 3px 1px rgb(var(--color-card-shadow) / 15%),
0 1px 2px rgb(var(--color-card-shadow) / 30%);

@include prefers-motion {
transition: transform 0.5s $ease-out;
}

[data-is-selected="true"] & {
transform: translateY(calc(var(--actions-height) * -1));
}
}

.children-container {
display: flex;
flex-direction: column;
grid-area: 1 / 1;
height: fit-content;
max-height: 100%;
overflow-y: auto;
background: rgb(var(--color-card-base));
border: var(--border-thickness) solid transparent;
border-radius: var(--corner-size);
box-shadow:
0 1px 3px 1px rgb(var(--color-card-shadow) / 15%),
0 1px 2px rgb(var(--color-card-shadow) / 30%);

@include prefers-motion {
transition:
transform 0.5s $ease-out,
border-color 0.5s $ease-out;
}

[data-is-selected="true"] & {
border-color: rgb(var(--color-card-base-selected));
transform: translateY(var(--actions-height));
}
}

.content {
display: flex;
flex-shrink: 0;
flex-direction: column;
padding: 28px;
}

.footer {
flex-shrink: 0;
padding: 0 28px 18px;

[data-loading="true"] & {
visibility: hidden;
pointer-events: none;
}
}
Loading