App reorganization
This commit is contained in:
@@ -1,59 +1,51 @@
|
|||||||
---
|
---
|
||||||
description: Figma ↔ codebase prop alignment & normalization for design-system components
|
description: Component prop conventions — lowercase-canonical enums, Figma traceability
|
||||||
globs: app/components/**/*.{ts,tsx}
|
globs: app/components/**/*.{ts,tsx}
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
# Component prop alignment
|
# Component prop alignment
|
||||||
|
|
||||||
Figma emits PascalCase enum values (`"Standard"`, `"Inverse"`); the codebase
|
Figma is the source of truth for component **design** (existence, variants,
|
||||||
stores lowercase (`"standard"`, `"inverse"`). Components must accept **both**,
|
visual specification). The codebase implements those components using
|
||||||
normalize to lowercase internally, and never break existing consumer call
|
idiomatic TypeScript naming. Enum props are **lowercase** in code; PascalCase
|
||||||
sites.
|
is a Figma-side concern only.
|
||||||
|
|
||||||
## Scope
|
## Enum prop convention
|
||||||
|
|
||||||
Applies to enum-like string props: `variant`, `size`, `state`, `mode`, `type`,
|
- Types use lowercase string unions: `"small" | "medium" | "large"`.
|
||||||
`position`, `alignment`, `status`, `color`, `palette`. **Skip** for free-form
|
- Do NOT add PascalCase variants to type unions.
|
||||||
strings (URLs, classNames), booleans, numbers, or internal-only props.
|
- Do NOT call normalizers in containers. The container layer is for `memo`,
|
||||||
|
derived state, prop defaults, and bound logic — not for casing translation.
|
||||||
## Pattern
|
- Each enum prop has a sibling `<COMPONENT>_<PROP>_OPTIONS as const` array
|
||||||
|
exported alongside the type. Storybook `argTypes` and any runtime guard
|
||||||
|
consume that array as the single source of valid values.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Type accepts both formats
|
export const CHIP_PALETTE_OPTIONS = ["primary", "secondary"] as const;
|
||||||
export type ComponentSizeValue = "small" | "medium" | "Small" | "Medium";
|
export type ChipPaletteValue = (typeof CHIP_PALETTE_OPTIONS)[number];
|
||||||
|
|
||||||
// Container normalizes via helpers from lib/propNormalization.ts
|
|
||||||
import { normalizeSize } from "../../../lib/propNormalization";
|
|
||||||
|
|
||||||
const ComponentContainer = ({ size: sizeProp = "small" }: Props) => {
|
|
||||||
const size = normalizeSize(sizeProp, SIZE_OPTIONS, "small");
|
|
||||||
return <ComponentView size={size} />;
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Normalizers
|
|
||||||
|
|
||||||
Use an existing helper from `lib/propNormalization.ts` before writing a new
|
|
||||||
one. Generic: `normalizeMode`, `normalizeState`, `normalizeInputState`,
|
|
||||||
`normalizeSize`, `normalizeAlignment`, `normalizeSmallMediumLargeSize`,
|
|
||||||
`normalizeLabelVariant`. Component-specific variants are named
|
|
||||||
`normalize<Component><Prop>` (e.g. `normalizeChipPalette`,
|
|
||||||
`normalizeButtonState`) — add one there rather than inlining a switch in your
|
|
||||||
container.
|
|
||||||
|
|
||||||
## Figma traceability
|
## Figma traceability
|
||||||
|
|
||||||
Every DS component's container docstring cites its Figma origin so designers
|
- Container docstring (required on every DS container): `Figma:
|
||||||
and engineers can jump between the two. Format: `Figma: "<Component Path>"
|
"<Component Path>" (<node-id>)`.
|
||||||
(<node-id>)`.
|
- View root element: `data-figma-node="<id>"` when the view maps to a
|
||||||
|
distinct Figma node.
|
||||||
|
- For create-flow screens, node ids come from `CREATE_FLOW_SCREEN_REGISTRY`
|
||||||
|
in `app/(app)/create/utils/createFlowScreenRegistry.ts`. For everything else,
|
||||||
|
pull the node id from the Figma file directly. Use `TODO(figma)` as a
|
||||||
|
placeholder rather than omitting the docstring entirely.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
/**
|
/**
|
||||||
* Figma: "Control / Incrementer" (`17857:30943`). A compact [ - value + ]
|
* Figma: "Control / Incrementer" (17857:30943). A compact [ - value + ]
|
||||||
* row used for numeric step inputs…
|
* row used for numeric step inputs.
|
||||||
*/
|
*/
|
||||||
```
|
```
|
||||||
|
|
||||||
When the view renders a distinct Figma node, add `data-figma-node="<id>"` on
|
## Pasting from Figma
|
||||||
the root element for quick DOM-to-design lookup.
|
|
||||||
|
Figma's "Inspect → Code" output emits PascalCase. When importing a snippet,
|
||||||
|
lowercase the enum values before committing — same pattern as removing
|
||||||
|
inline pixel values in favor of design tokens.
|
||||||
|
|||||||
@@ -22,24 +22,27 @@ app/components/controls/<Name>/
|
|||||||
**Container** (`<Name>.container.tsx`):
|
**Container** (`<Name>.container.tsx`):
|
||||||
|
|
||||||
- Marked `"use client"`.
|
- Marked `"use client"`.
|
||||||
- Receives `<Name>Props`; normalizes PascalCase enums via
|
- Receives `<Name>Props`; computes derived state (clamps, ids, bounds, prop
|
||||||
`lib/propNormalization.ts`; computes derived state (clamps, ids, bounds).
|
defaults) and bound event handlers.
|
||||||
- Renders `<<Name>View />` with already-normalized `<Name>ViewProps`.
|
- Renders `<<Name>View />`. Containers do **not** translate prop casing —
|
||||||
|
enum props are lowercase end-to-end (see `component-props.mdc`).
|
||||||
- Default export: `memo(<Name>Container)` with `.displayName = "<Name>"`.
|
- Default export: `memo(<Name>Container)` with `.displayName = "<Name>"`.
|
||||||
|
- Carries the Figma docstring (`Figma: "<Path>" (<node-id>)`).
|
||||||
|
|
||||||
**View** (`<Name>.view.tsx`):
|
**View** (`<Name>.view.tsx`):
|
||||||
|
|
||||||
- Marked `"use client"`.
|
- Marked `"use client"`.
|
||||||
- Pure render of `<Name>ViewProps`. No prop normalization, no data fetching,
|
- Pure render of `<Name>ViewProps`. No data fetching, no derived business
|
||||||
no derived business logic.
|
logic, no enum casing translation.
|
||||||
- Default export: `memo(<Name>View)` with
|
- Default export: `memo(<Name>View)` with `.displayName = "<Name>View"`.
|
||||||
`.displayName = "<Name>View"`.
|
|
||||||
|
|
||||||
**Types** (`<Name>.types.ts`):
|
**Types** (`<Name>.types.ts`):
|
||||||
|
|
||||||
- Export `<Name>Props` (consumer-facing, accepts PascalCase + lowercase).
|
- Export `<Name>Props` (consumer-facing).
|
||||||
- Export `<Name>ViewProps` (already-normalized shape the view consumes).
|
- Export `<Name>ViewProps` (the shape the view consumes — typically a
|
||||||
- Export any locally-defined value types (`<Name>SizeValue`, etc.).
|
resolved superset of `<Name>Props`).
|
||||||
|
- Export any locally-defined value types (`<Name>SizeValue`, etc.) sourced
|
||||||
|
from the matching `*_OPTIONS` array in `lib/propNormalization.ts`.
|
||||||
|
|
||||||
**Index** (`index.tsx`):
|
**Index** (`index.tsx`):
|
||||||
|
|
||||||
@@ -51,10 +54,10 @@ export type { <Name>Props } from "./<Name>.types";
|
|||||||
## Single-file pattern (exception)
|
## Single-file pattern (exception)
|
||||||
|
|
||||||
`app/components/buttons/*.tsx` and other trivially-presentational components
|
`app/components/buttons/*.tsx` and other trivially-presentational components
|
||||||
can stay as a single file when they have **no enum prop normalization and no
|
can stay as a single file when they have **no derived state and only a
|
||||||
derived state** (e.g. `Button.tsx`, `InlineTextButton.tsx`). If you find
|
handful of props** (e.g. `Button.tsx`, `InlineTextButton.tsx`). If you find
|
||||||
yourself adding state, enum normalization, or more than a handful of props,
|
yourself adding state, side effects, or enum logic, promote it to the split
|
||||||
promote it to the split pattern.
|
pattern.
|
||||||
|
|
||||||
## Wrapper / group components
|
## Wrapper / group components
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
description: Create-flow structure & design-system reuse guardrails
|
description: Create-flow structure & design-system reuse guardrails
|
||||||
globs: app/create/**/*.{ts,tsx},messages/en/create/**/*.json
|
globs: app/(app)/create/**/*.{ts,tsx},messages/en/create/**/*.json
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -8,15 +8,15 @@ alwaysApply: false
|
|||||||
|
|
||||||
## Folder & file layout
|
## Folder & file layout
|
||||||
|
|
||||||
- Screens live in `app/create/screens/<layoutKind>/<StepIdPascal>Screen.tsx`
|
- Screens live in `app/(app)/create/screens/<layoutKind>/<StepIdPascal>Screen.tsx`
|
||||||
where `<layoutKind>` mirrors `CreateFlowLayoutKind` (`card`, `select`,
|
where `<layoutKind>` mirrors `CreateFlowLayoutKind` (`card`, `select`,
|
||||||
`right-rail`, `completed`, …). File + export name is the **step id**, never
|
`right-rail`, `completed`, …). File + export name is the **step id**, never
|
||||||
the layout kind (e.g. `DecisionApproachesScreen`, not `RightRailScreen`).
|
the layout kind (e.g. `DecisionApproachesScreen`, not `RightRailScreen`).
|
||||||
- Step id ↔ layout kind mapping is declared in
|
- Step id ↔ layout kind mapping is declared in
|
||||||
`app/create/utils/createFlowScreenRegistry.ts`. Never branch on layout kind
|
`app/(app)/create/utils/createFlowScreenRegistry.ts`. Never branch on layout kind
|
||||||
inside a screen — pick the matching shell (`CreateFlowStepShell` /
|
inside a screen — pick the matching shell (`CreateFlowStepShell` /
|
||||||
`CreateFlowTwoColumnSelectShell`).
|
`CreateFlowTwoColumnSelectShell`).
|
||||||
- Shared create-flow pieces go in `app/create/components/` (layout shells,
|
- Shared create-flow pieces go in `app/(app)/create/components/` (layout shells,
|
||||||
field composites). Generic primitives go in `app/components/`.
|
field composites). Generic primitives go in `app/components/`.
|
||||||
|
|
||||||
## Use the design system — don't hand-roll
|
## Use the design system — don't hand-roll
|
||||||
@@ -25,8 +25,8 @@ Reach for these before writing new markup:
|
|||||||
|
|
||||||
| Need | Component |
|
| Need | Component |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Labelled text-area section in a modal | `app/create/components/ModalTextAreaField` |
|
| Labelled text-area section in a modal | `app/(app)/create/components/ModalTextAreaField` |
|
||||||
| Toggle-chip row + inline "+ Add" input | `app/create/components/ApplicableScopeField` |
|
| Toggle-chip row + inline "+ Add" input | `app/(app)/create/components/ApplicableScopeField` |
|
||||||
| `[– value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` |
|
| `[– value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` |
|
||||||
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
|
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
|
||||||
| Help-icon + label above a control | `app/components/utility/InputLabel` (`helpIcon` prop) |
|
| Help-icon + label above a control | `app/components/utility/InputLabel` (`helpIcon` prop) |
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
description: Custom hooks live in app/hooks; co-locate logic, document via TSDoc.
|
||||||
|
globs: app/hooks/**/*.{ts,tsx}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Custom hooks
|
||||||
|
|
||||||
|
Reusable component logic lives in `app/hooks/`. Each hook is a small, focused
|
||||||
|
module with a TSDoc block that doubles as the API reference (no separate doc
|
||||||
|
file).
|
||||||
|
|
||||||
|
## File layout
|
||||||
|
|
||||||
|
- One file per hook: `app/hooks/use<Name>.ts`.
|
||||||
|
- Re-export from `app/hooks/index.ts`. Consumers import from the barrel:
|
||||||
|
`import { useFoo } from "../hooks";`.
|
||||||
|
- Companion unit test (when there is non-trivial logic): `tests/unit/hooks/`.
|
||||||
|
|
||||||
|
## Authoring rules
|
||||||
|
|
||||||
|
- Marked as a regular function (`export function useFoo() {}`); React handles
|
||||||
|
the `use*` naming convention.
|
||||||
|
- Wrap exposed callbacks in `useCallback` and computed values in `useMemo`
|
||||||
|
so consumers can list them in dependency arrays without churn.
|
||||||
|
- Read DOM/browser APIs only inside `useEffect` so the hook stays SSR-safe.
|
||||||
|
- Never throw on missing globals (e.g. `window`, `gtag`); guard and no-op.
|
||||||
|
|
||||||
|
## TSDoc — the only reference
|
||||||
|
|
||||||
|
Every exported hook gets a TSDoc block with:
|
||||||
|
|
||||||
|
- 1–2 sentence summary.
|
||||||
|
- `@param` per argument and `@returns` describing the shape.
|
||||||
|
- `@example` showing the typical call site.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/**
|
||||||
|
* Detect clicks outside a set of elements (e.g. close a dropdown).
|
||||||
|
*
|
||||||
|
* @param refs Elements that should NOT trigger the handler.
|
||||||
|
* @param handler Invoked when a click lands outside every ref.
|
||||||
|
* @param enabled Toggle without unmounting the consumer (default true).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* useClickOutside([menuRef, buttonRef], () => setOpen(false), open);
|
||||||
|
*/
|
||||||
|
export function useClickOutside(
|
||||||
|
refs: Array<RefObject<HTMLElement>>,
|
||||||
|
handler: (event: MouseEvent | TouchEvent) => void,
|
||||||
|
enabled = true,
|
||||||
|
): void { /* ... */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Container/view consumption
|
||||||
|
|
||||||
|
Hooks belong in **container** files (per `component-structure.mdc`). Views
|
||||||
|
stay pure and read derived values via props — never call hooks that touch
|
||||||
|
state or side effects from a view.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
description: App Router route organization (groups, layouts, chrome composition)
|
||||||
|
globs: app/**/*.{ts,tsx}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Route organization
|
||||||
|
|
||||||
|
Top-level routes live inside **route groups** so each surface owns its own
|
||||||
|
layout and chrome. Groups are wrapping folders in `(parens)` — they organize
|
||||||
|
the file tree without affecting URLs.
|
||||||
|
|
||||||
|
## Group map
|
||||||
|
|
||||||
|
| Group | URL surface | Audience | Chrome |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | TopNav (via root) + marketing `<Footer />` |
|
||||||
|
| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | TopNav (via root) — no footer |
|
||||||
|
| `app/(admin)/` | `/monitor`, future ops dashboards | Operators | TopNav (via root) — no footer |
|
||||||
|
| `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | TopNav (via root) — no footer |
|
||||||
|
| `app/api/` | API routes | n/a | n/a |
|
||||||
|
|
||||||
|
Route folders **must not** sit loose at the top level of `app/`. If a new
|
||||||
|
surface doesn't fit an existing group, add a new group rather than dropping
|
||||||
|
the folder next to `(marketing)/`.
|
||||||
|
|
||||||
|
## Layout responsibilities
|
||||||
|
|
||||||
|
- **`app/layout.tsx`** — `<html>`, `<body>`, providers (`MessagesProvider`,
|
||||||
|
`AuthModalProvider`), fonts, and `ConditionalNavigation`. Renders
|
||||||
|
`{children}` directly inside the flex column. **Does not** render
|
||||||
|
`<main>` — each group layout owns that.
|
||||||
|
- **`app/(marketing)/layout.tsx`** — wraps with `<main className="flex-1">`
|
||||||
|
and appends the public `<Footer />`.
|
||||||
|
- **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** / **`(dev)/layout.tsx`** —
|
||||||
|
wrap with `<main className="flex-1">`. No footer.
|
||||||
|
- **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific
|
||||||
|
chrome inside the group's `<main>` — never render `<html>`, `<body>`,
|
||||||
|
`<main>`, or providers.
|
||||||
|
|
||||||
|
If a route needs different chrome than its group provides, prefer adding a
|
||||||
|
**nested layout** under that route — don't introduce pathname-sniffing
|
||||||
|
client components. (`ConditionalNavigation` is the lone tolerated exception
|
||||||
|
because it carries SSR session state; do not add new pathname-conditional
|
||||||
|
chrome components.)
|
||||||
|
|
||||||
|
## Co-located component folders
|
||||||
|
|
||||||
|
Page-private server/client components that are **only** used by routes in a
|
||||||
|
given group go in `_components/` inside that group:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/(marketing)/_components/MarketingRuleStackSection.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
The leading underscore makes Next.js treat the folder as **private** — it's
|
||||||
|
ignored by the router. Use this instead of letting page-only files sit next
|
||||||
|
to `page.tsx`.
|
||||||
|
|
||||||
|
Components reused across groups belong in `app/components/<category>/`
|
||||||
|
(see `component-structure.mdc`).
|
||||||
|
|
||||||
|
## Adding a new route
|
||||||
|
|
||||||
|
1. **Choose the group** by audience: marketing (public), app (signed-in),
|
||||||
|
admin (operators), dev (local-only). When in doubt, ask whether the
|
||||||
|
public marketing footer should appear — if yes, it's `(marketing)`.
|
||||||
|
2. Create `app/(<group>)/<route>/page.tsx`. URLs do **not** include the
|
||||||
|
group name.
|
||||||
|
3. If the route needs its own chrome (e.g. a wizard header), add
|
||||||
|
`app/(<group>)/<route>/layout.tsx`.
|
||||||
|
4. If the route ships private helpers, put them in
|
||||||
|
`app/(<group>)/<route>/_components/` (or
|
||||||
|
`app/(<group>)/_components/` for group-wide page components).
|
||||||
|
|
||||||
|
## Splitting a group
|
||||||
|
|
||||||
|
Promote a sub-cluster to its own group only when **both** are true:
|
||||||
|
|
||||||
|
- It will hold ≥2 routes that share a layout, **or** it has a clearly
|
||||||
|
distinct audience/access model (e.g. a future `(auth)/` for
|
||||||
|
signup/forgot/verify alongside login).
|
||||||
|
- Moving the routes pays for itself by replacing existing pathname
|
||||||
|
conditionals or by composing real shared chrome — not just by tidying
|
||||||
|
the folder list.
|
||||||
|
|
||||||
|
YAGNI applies: a group with one route and no shared layout is just a
|
||||||
|
folder with parens.
|
||||||
+19
-10
@@ -6,14 +6,22 @@ alwaysApply: false
|
|||||||
|
|
||||||
# Where stories live
|
# Where stories live
|
||||||
|
|
||||||
All stories live in the top-level `stories/` folder, mirroring the
|
All stories live in the top-level `stories/` folder. Two layout rules:
|
||||||
`app/components/` directory tree:
|
|
||||||
|
|
||||||
| Source | Story location |
|
- **Design-system components** mirror `app/components/`. A component at
|
||||||
| --------------------------------- | -------------------------------------- |
|
`app/components/<bucket>/<Name>` gets `stories/<bucket>/<Name>.stories.js`.
|
||||||
| `app/components/controls/Chip` | `stories/controls/Chip.stories.js` |
|
- **Create-flow material** has two carve-outs:
|
||||||
| `app/components/buttons/Button` | `stories/buttons/Button.stories.js` |
|
- `stories/create-flow/` — shared create-flow pieces that aren't in
|
||||||
| `app/create/screens/.../FooScreen`| `stories/pages/FooPage.stories.js` |
|
`app/components/` (e.g. composed wizard fragments).
|
||||||
|
- `stories/pages/` — integration stories that exercise an entire
|
||||||
|
`app/(app)/create/screens/<...>` screen as it appears in the wizard.
|
||||||
|
|
||||||
|
| Source | Story location |
|
||||||
|
| --------------------------------- | --------------------------------------- |
|
||||||
|
| `app/components/controls/Chip` | `stories/controls/Chip.stories.js` |
|
||||||
|
| `app/components/buttons/Button` | `stories/buttons/Button.stories.js` |
|
||||||
|
| `app/(app)/create/screens/.../FooScreen`| `stories/pages/FooPage.stories.js` |
|
||||||
|
| Shared create-flow fragment | `stories/create-flow/<Name>.stories.js` |
|
||||||
|
|
||||||
Do **not** colocate `*.stories.*` next to components. The Storybook config
|
Do **not** colocate `*.stories.*` next to components. The Storybook config
|
||||||
(`.storybook/main.js`) only globs `stories/**`.
|
(`.storybook/main.js`) only globs `stories/**`.
|
||||||
@@ -65,8 +73,9 @@ export const Default = { args: { variant: "filled" } };
|
|||||||
## `argTypes`
|
## `argTypes`
|
||||||
|
|
||||||
For every Figma enum prop (`variant`, `size`, `state`, `mode`, `palette`,
|
For every Figma enum prop (`variant`, `size`, `state`, `mode`, `palette`,
|
||||||
…) expose a `select` control listing the **lowercase** option set — even
|
…) expose a `select` control listing the **lowercase** option set, sourced
|
||||||
though the prop accepts PascalCase too. See `.cursor/rules/component-props.mdc`.
|
from the matching `*_OPTIONS` const in `lib/propNormalization.ts`. See
|
||||||
|
`.cursor/rules/component-props.mdc`.
|
||||||
|
|
||||||
# Rely on the global preview — don't re-wrap
|
# Rely on the global preview — don't re-wrap
|
||||||
|
|
||||||
@@ -101,5 +110,5 @@ export const Interactive = {
|
|||||||
# Coverage expectation
|
# Coverage expectation
|
||||||
|
|
||||||
Every new component in `app/components/**` ships with a story. Screens in
|
Every new component in `app/components/**` ships with a story. Screens in
|
||||||
`app/create/screens/**` ship with a `stories/pages/<Name>Page.stories.js`
|
`app/(app)/create/screens/**` ship with a `stories/pages/<Name>Page.stories.js`
|
||||||
entry. A new component without a story is considered incomplete.
|
entry. A new component without a story is considered incomplete.
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Agent guide
|
||||||
|
|
||||||
|
Orientation for AI coding agents working in this repo. Per-file conventions
|
||||||
|
live in `.cursor/rules/*.mdc` (auto-loaded by Cursor; other agents should
|
||||||
|
read them on demand). This file is the **map** — load it first, then load
|
||||||
|
the rule(s) matching the file you're editing.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
Next.js 16 / React 19 app for community decision-making and governance.
|
||||||
|
Single-locale (English) today; designed for i18n via `messages/`.
|
||||||
|
|
||||||
|
## Read before editing
|
||||||
|
|
||||||
|
| If you're touching… | Load this rule |
|
||||||
|
| --- | --- |
|
||||||
|
| `app/components/**` | `component-structure.mdc`, `component-props.mdc`, `tailwind-styling.mdc` |
|
||||||
|
| `app/(app)/create/**` | `create-flow.mdc` (+ component rules) |
|
||||||
|
| `app/api/**` | `api-routes.mdc` |
|
||||||
|
| `app/hooks/**` | `hooks.mdc` |
|
||||||
|
| `app/**/page.tsx` or `app/**/layout.tsx` | `routes.mdc` |
|
||||||
|
| `messages/**` or any user-visible string | `localization.mdc` |
|
||||||
|
| `tests/**` | `testing.mdc` |
|
||||||
|
| `stories/**` | `storybook.mdc` |
|
||||||
|
|
||||||
|
When in doubt about file structure or naming, the rules win over your
|
||||||
|
priors — they reflect deliberate decisions.
|
||||||
|
|
||||||
|
## Cross-cutting principles (no single rule owns these)
|
||||||
|
|
||||||
|
1. **Figma is the source of truth for design.** Container files carry a
|
||||||
|
`Figma: "<Path>" (<node-id>)` docstring; views render Figma intent.
|
||||||
|
Codebase naming uses lowercase conventions (see `component-props.mdc`)
|
||||||
|
even when Figma uses PascalCase enum values.
|
||||||
|
2. **Container / view split is the component pattern.** Never put state
|
||||||
|
or side effects in a `*.view.tsx`. Hooks belong in containers.
|
||||||
|
3. **All user-visible text lives in `messages/`.** Hardcoded strings in
|
||||||
|
components are a bug — even for placeholders.
|
||||||
|
4. **Tests live in `tests/`, not co-located.** Mirror the source path
|
||||||
|
(`app/components/Foo` → `tests/components/Foo.test.tsx`).
|
||||||
|
5. **Routes live inside groups** — `(marketing)`, `(app)`, `(admin)`,
|
||||||
|
`(dev)`. Don't drop a new route folder loose at the top of `app/`.
|
||||||
|
6. **No new pathname-sniffing chrome.** Compose chrome via group/nested
|
||||||
|
layouts, not `usePathname()` checks. (`ConditionalNavigation` is the
|
||||||
|
sole tolerated exception — it carries SSR session state.)
|
||||||
|
|
||||||
|
## Legacy / scaffolding
|
||||||
|
|
||||||
|
Some code exists temporarily while backend services are stood up:
|
||||||
|
|
||||||
|
- `NEXT_PUBLIC_ENABLE_BACKEND_SYNC` gating
|
||||||
|
- `migrateLegacyCreateFlowState`, `LEGACY_LIVE_KEY`, `LEGACY_DRAFT_KEY`
|
||||||
|
- `/create/right-rail` redirect
|
||||||
|
- `docs/guides/backend-roadmap.md`, `backend-linear-tickets.md`,
|
||||||
|
`template-recommendation-matrix.md`
|
||||||
|
|
||||||
|
**Do not delete** without an explicit ask. Do not add new code in this
|
||||||
|
shape — when adding scaffolding, leave a `// TODO(legacy): …` with the
|
||||||
|
removal trigger.
|
||||||
|
|
||||||
|
## Verification recipe
|
||||||
|
|
||||||
|
Run these (in order) before declaring a change done. They mirror CI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf .next # only if you moved/renamed routes or layouts
|
||||||
|
npx tsc --noEmit # type check
|
||||||
|
npx vitest run # unit + component (101 files / ~700 tests)
|
||||||
|
npx next build # production build + route manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
For UI-only changes, also: `npm run storybook` and visually confirm.
|
||||||
|
For E2E-relevant changes: `npm run e2e`.
|
||||||
|
|
||||||
|
## Where else to look
|
||||||
|
|
||||||
|
- [README.md](README.md) — human onboarding, scripts, project layout.
|
||||||
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — local Postgres + Prisma + magic-link
|
||||||
|
setup, PR workflow.
|
||||||
|
- [docs/README.md](docs/README.md) — index of user-facing docs.
|
||||||
|
- [docs/create-flow.md](docs/create-flow.md) — wizard URL/persistence canon
|
||||||
|
(read alongside `create-flow.mdc`).
|
||||||
+58
-33
@@ -1,51 +1,76 @@
|
|||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
## Backend (local)
|
## Local backend
|
||||||
|
|
||||||
1. Copy [`.env.example`](.env.example) to `.env` and set `SESSION_SECRET` (at least 16 characters).
|
1. Copy [`.env.example`](.env.example) to `.env` and set `SESSION_SECRET`
|
||||||
2. `docker compose up -d postgres mailhog` — omit `mailhog` if you only need Postgres; with `SMTP_URL` unset, the **magic-link verify URL** is printed in the dev server log (see `.env.example`).
|
(at least 16 characters).
|
||||||
3. Install dependencies: `npm ci`
|
2. `docker compose up -d postgres mailhog` — omit `mailhog` if you only
|
||||||
4. Apply migrations: `npx prisma migrate dev`
|
need Postgres. Without `SMTP_URL`, the **magic-link verify URL** is
|
||||||
5. (Optional) Seed curated rule templates: `npx prisma db seed` — requires `DATABASE_URL` and applied migrations. Safe to re-run; rows are upserted by `slug` so duplicates are not created.
|
printed in the dev server log.
|
||||||
6. Run the app: `npm run dev`
|
3. `npm ci`
|
||||||
|
4. `npx prisma migrate dev`
|
||||||
|
5. *(Optional)* `npx prisma db seed` — seeds curated rule templates.
|
||||||
|
Idempotent; rows upsert by `slug`.
|
||||||
|
6. `npm run dev`
|
||||||
|
|
||||||
Use `npx prisma studio` to inspect the database.
|
Use `npx prisma studio` to inspect the database.
|
||||||
|
|
||||||
### Prisma migrations (important)
|
### Prisma migrations
|
||||||
|
|
||||||
- **Do not edit** migration files that have **already been applied** to **staging, production, or any shared database**. Changing history breaks `migrate deploy` and other environments.
|
- **Never edit** a migration that has already been applied to staging,
|
||||||
- To fix a bad migration, add a **new** migration that corrects the schema. See [docs/backend-roadmap.md](docs/backend-roadmap.md) §8 for the full policy.
|
production, or any shared database. Add a **new** migration that
|
||||||
|
corrects the schema instead. Full policy:
|
||||||
|
[docs/guides/backend-roadmap.md](docs/guides/backend-roadmap.md) §8.
|
||||||
|
|
||||||
### API routes (overview)
|
### API routes
|
||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
| ---------- | ------------------------------ | --------------------------------------------- |
|
| --- | --- | --- |
|
||||||
| GET | `/api/health` | Liveness / DB check |
|
| GET | `/api/health` | Liveness / DB check. |
|
||||||
| GET | `/api/auth/session` | Current user or null |
|
| GET | `/api/auth/session` | Current user or null. |
|
||||||
| POST | `/api/auth/magic-link/request` | Send sign-in link email |
|
| POST | `/api/auth/magic-link/request` | Send sign-in link email. |
|
||||||
| GET | `/api/auth/magic-link/verify` | Validate token, set session cookie, redirect |
|
| GET | `/api/auth/magic-link/verify` | Validate token, set cookie, redirect. |
|
||||||
| POST | `/api/auth/logout` | Clear session |
|
| POST | `/api/auth/logout` | Clear session. |
|
||||||
| GET / PUT | `/api/drafts/me` | Load or save create-flow JSON (authenticated) |
|
| GET / PUT | `/api/drafts/me` | Load or save the create-flow draft. |
|
||||||
| GET / POST | `/api/rules` | List or publish rules (each **Finalize** creates a new published row until an update/edit-published API exists) |
|
| GET / POST | `/api/rules` | List or publish rules. |
|
||||||
| GET | `/api/templates` | List curated templates |
|
| GET | `/api/templates` | List curated templates. |
|
||||||
|
|
||||||
### Email magic link (sign-in)
|
### Magic-link sign-in
|
||||||
|
|
||||||
- Open **[http://localhost:3000/login](http://localhost:3000/login)** or use **Log in** in the site header (modal or full page).
|
- Visit **[/login](http://localhost:3000/login)** or use **Log in** in the
|
||||||
- Enter email and request a link. Complete sign-in by opening the link in the **same browser** you use for the app (session cookie).
|
site header.
|
||||||
- **No `SMTP_URL`:** the full **`GET /api/auth/magic-link/verify?...`** URL is printed in the **dev server terminal** — paste it into the browser address bar.
|
- Without `SMTP_URL`: copy the verify URL from the dev server terminal.
|
||||||
- **Mailhog:** with Compose Mailhog running, set `SMTP_URL=smtp://localhost:1025` and open the link from the message in the Mailhog UI ([http://localhost:8025](http://localhost:8025)).
|
- With Mailhog: set `SMTP_URL=smtp://localhost:1025` and open the message
|
||||||
|
at [http://localhost:8025](http://localhost:8025).
|
||||||
**Staging / production:** Sign-in links use the app’s origin. Ensure your reverse proxy sets **`Host`** (and TLS) so links in email match the URL users open. See [docs/backend-roadmap.md](docs/backend-roadmap.md) §9.
|
- Open the link in the **same browser** as the app (session cookie).
|
||||||
|
|
||||||
### Optional draft sync
|
### Optional draft sync
|
||||||
|
|
||||||
Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` so **signed-in** users can persist create-flow drafts to Postgres via **Save & Exit** and so **anonymous** progress can be **uploaded after magic-link sign-in** from the save-progress exit modal. Without it, server **PUT** `/api/drafts/me` is skipped; anonymous work stays in **browser `localStorage`**, but after sign-in with a `?syncDraft=1` return URL the app still **merges that local draft into the in-memory create flow** (no server write) so you can continue and publish.
|
`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` enables Postgres draft persistence
|
||||||
|
via `PUT /api/drafts/me` for signed-in users and post-sign-in upload of
|
||||||
|
anonymous drafts. Without it, anonymous progress stays in `localStorage`
|
||||||
|
and signed-in progress stays in memory until **Save & Exit**.
|
||||||
|
|
||||||
### Create flow URLs (custom wizard)
|
### Create flow
|
||||||
|
|
||||||
The **custom** create-rule wizard lives under **`/create/…`**. The header links to **`/create`**, which redirects to the first step. **Semantic** URL segments (e.g. `community-name`, `community-size`) match Figma intent; order is **`FLOW_STEP_ORDER`** in `app/create/utils/flowSteps.ts`, with UI from **`app/create/[screenId]/page.tsx`** and **`CREATE_FLOW_SCREEN_REGISTRY`** for Figma traceability. **Figma** stages: **Create Community** (through `review`), **Create Custom CommunityRule** (`communication-methods`–`right-rail`), **Review and complete** (`confirm-stakeholders`–`completed`). **`/create/review-template/[slug]`** is a template **preview** only. Full tables and persistence are in **[docs/create-flow.md](docs/create-flow.md)**; engineering tracking: Linear **CR-89** / Ticket 17 in [docs/backend-linear-tickets.md](docs/backend-linear-tickets.md).
|
The custom wizard lives under `/create/…`. Step order, URLs, and Figma
|
||||||
|
stage mapping are canon in [docs/create-flow.md](docs/create-flow.md).
|
||||||
|
Engineering tracking: Linear **CR-89** /
|
||||||
|
[docs/guides/backend-linear-tickets.md](docs/guides/backend-linear-tickets.md)
|
||||||
|
Ticket 17.
|
||||||
|
|
||||||
## Frontend / tests
|
## Frontend & tests
|
||||||
|
|
||||||
See [docs/TESTING_GUIDE.md](docs/TESTING_GUIDE.md) and the root [README.md](README.md).
|
- Code conventions are enforced by `.cursor/rules/*.mdc` — Cursor surfaces
|
||||||
|
the relevant rule when editing matching files.
|
||||||
|
- See [docs/testing-guide.md](docs/testing-guide.md) for testing
|
||||||
|
philosophy and `.cursor/rules/testing.mdc` for layout/helpers.
|
||||||
|
|
||||||
|
## Pull request workflow
|
||||||
|
|
||||||
|
1. Branch from `main`: `git checkout -b feature/<short-name>`.
|
||||||
|
2. Make the change and add/update tests.
|
||||||
|
3. `npm test && npm run e2e` (and `npm run storybook:build` if you touched
|
||||||
|
stories).
|
||||||
|
4. Commit using a clear message (`feat:`, `fix:`, `chore:`, …).
|
||||||
|
5. Open a PR; CI runs unit, E2E, visual regression, and Lighthouse.
|
||||||
|
|||||||
@@ -1,252 +1,64 @@
|
|||||||
# Community Rule
|
# Community Rule
|
||||||
|
|
||||||
A Next.js application for community decision-making and governance documentation.
|
A Next.js application for community decision-making and governance
|
||||||
|
documentation.
|
||||||
|
|
||||||
## 📋 Requirements
|
## Requirements
|
||||||
|
|
||||||
- **Node.js**: 20.0.0 or higher (LTS recommended)
|
- Node.js **20+** (LTS)
|
||||||
- **npm**: 10.0.0 or higher
|
- npm **10+**
|
||||||
|
|
||||||
## 🚀 Getting Started
|
## Getting started
|
||||||
|
|
||||||
Run the development server:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm ci
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Open [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
Backend (Postgres, Prisma, API routes) setup is documented in [CONTRIBUTING.md](CONTRIBUTING.md).
|
Backend setup (Postgres, Prisma, magic-link auth) is documented in
|
||||||
|
[CONTRIBUTING.md](CONTRIBUTING.md).
|
||||||
|
|
||||||
## 🧪 Testing Framework
|
## Common scripts
|
||||||
|
|
||||||
This project uses a simplified, component‑first testing model:
|
| Command | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `npm run dev` | Next.js dev server (Turbopack). |
|
||||||
|
| `npm run build` / `npm start` | Production build / serve. |
|
||||||
|
| `npm test` | Vitest unit + component tests with coverage. |
|
||||||
|
| `npm run test:component` | Faster inner loop — components only. |
|
||||||
|
| `npm run e2e` | Playwright E2E + visual regression. |
|
||||||
|
| `npm run storybook` | Storybook on port 6006. |
|
||||||
|
| `npm run lhci` | Lighthouse CI performance pass. |
|
||||||
|
|
||||||
- **Component tests (Vitest + RTL)** live in `tests/components/` with a single file per component.
|
## Project layout
|
||||||
- **E2E tests (Playwright)** cover critical user journeys and visual regression.
|
|
||||||
|
|
||||||
### Quick Test Commands
|
```text
|
||||||
|
app/ Next.js app router (routes, components, hooks, contexts)
|
||||||
```bash
|
lib/ Shared library code (i18n, validation, utilities)
|
||||||
# All component tests with coverage
|
messages/en/ Localized UI copy (see docs/guides/i18n-translation-workflow.md)
|
||||||
npm test
|
prisma/ Database schema, migrations, seed
|
||||||
|
public/ Static assets
|
||||||
# Component tests only (new structure)
|
stories/ Storybook stories
|
||||||
npm run test:component
|
tests/ Vitest + Playwright suites
|
||||||
|
docs/ User-facing documentation (start with docs/README.md)
|
||||||
# E2E tests only
|
.cursor/rules/ Implementation conventions enforced by Cursor
|
||||||
npm run test:e2e
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test Coverage
|
## Tech stack
|
||||||
|
|
||||||
- ✅ **428 Unit Tests** (94.88% coverage - exceeds 85% target)
|
Next.js 16 · React 19 · TypeScript · Tailwind CSS 4 · Prisma · Vitest ·
|
||||||
- ✅ **92 E2E Tests** across 4 browsers
|
Playwright · Storybook 10 · Lighthouse CI.
|
||||||
- ✅ **23 Visual Regression Tests** per browser
|
|
||||||
- ✅ **Performance Budgets** with Lighthouse CI
|
|
||||||
- ✅ **WCAG 2.1 AA Compliance** with automated testing
|
|
||||||
- ✅ **Bundle Analysis** with automated monitoring
|
|
||||||
- ✅ **Web Vitals Tracking** with real-time metrics
|
|
||||||
|
|
||||||
### CI/CD Pipeline
|
## Documentation
|
||||||
|
|
||||||
- **Gitea Actions** with 7 parallel jobs
|
- [docs/README.md](docs/README.md) — index of guides and rules.
|
||||||
- **Cross-browser testing** (Chromium, Firefox, WebKit, Mobile)
|
- [docs/create-flow.md](docs/create-flow.md) — create-rule wizard canon.
|
||||||
- **Visual regression testing**
|
- [docs/testing-guide.md](docs/testing-guide.md) — testing philosophy.
|
||||||
- **Performance monitoring**
|
- [CONTRIBUTING.md](CONTRIBUTING.md) — local backend, API routes, PR
|
||||||
- **Code coverage reporting**
|
workflow.
|
||||||
|
|
||||||
📖 **For detailed testing documentation, see `docs/TESTING_GUIDE.md` and [docs/README.md](docs/README.md)**
|
## License
|
||||||
|
|
||||||
## ⚡ Performance Optimizations
|
[MIT](LICENSE).
|
||||||
|
|
||||||
This project includes comprehensive performance optimizations for sub-2-second load times:
|
|
||||||
|
|
||||||
### Frontend Optimizations
|
|
||||||
|
|
||||||
- **✅ Code Splitting**: Dynamic imports for non-critical components
|
|
||||||
- **✅ React.memo**: Applied to all 30+ components to prevent unnecessary re-renders
|
|
||||||
- **✅ Image Optimization**: Enhanced `next/image` with lazy loading and blur placeholders
|
|
||||||
- **✅ Font Optimization**: Preloading and fallbacks for all fonts
|
|
||||||
- **✅ Bundle Analysis**: Real-time monitoring with performance budgets
|
|
||||||
- **✅ Error Boundaries**: Comprehensive error handling
|
|
||||||
|
|
||||||
### Performance Monitoring
|
|
||||||
|
|
||||||
Performance testing is handled by:
|
|
||||||
|
|
||||||
- **Lighthouse CI** (`.lighthouserc.json`): Comprehensive performance testing in CI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run lhci # Run Lighthouse CI
|
|
||||||
npm run lhci:mobile # Mobile preset
|
|
||||||
npm run lhci:desktop # Desktop preset
|
|
||||||
npm run performance:budget # With performance budgets
|
|
||||||
```
|
|
||||||
|
|
||||||
- **E2E Performance Tests** (`tests/e2e/performance.spec.ts`): Essential performance checks
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run e2e:performance # Run E2E performance tests
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Bundle Analysis**: Analyze bundle sizes
|
|
||||||
```bash
|
|
||||||
npm run bundle:analyze # Analyze bundle sizes
|
|
||||||
```
|
|
||||||
|
|
||||||
### Performance Targets
|
|
||||||
|
|
||||||
- **Bundle Size**: <250KB gzipped (currently 101KB) ✅
|
|
||||||
- **Core Web Vitals**: All metrics in "Good" range ✅
|
|
||||||
- **Lighthouse Score**: >90 on all critical pages ✅
|
|
||||||
- **Load Time**: <2 seconds on 3G connections ✅
|
|
||||||
|
|
||||||
## 📚 Storybook Development
|
|
||||||
|
|
||||||
This project includes Storybook for component development and documentation. The setup automatically detects the environment and applies the appropriate configuration.
|
|
||||||
|
|
||||||
### Local Development
|
|
||||||
|
|
||||||
For local Storybook development:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run storybook:local
|
|
||||||
# or simply
|
|
||||||
npm run storybook
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
|
|
||||||
- Start Storybook at `http://localhost:6006`
|
|
||||||
- Use relative paths for assets (no base path)
|
|
||||||
|
|
||||||
### GitHub Pages Deployment
|
|
||||||
|
|
||||||
For GitHub Pages deployment with base path:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run storybook:build:github
|
|
||||||
```
|
|
||||||
|
|
||||||
This will:
|
|
||||||
|
|
||||||
- Build Storybook with `/communityrulestorybook/` base path
|
|
||||||
- Generate files ready for GitHub Pages deployment
|
|
||||||
|
|
||||||
### CI/CD Integration
|
|
||||||
|
|
||||||
The CI pipeline automatically uses the GitHub Pages configuration when building Storybook.
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
The Storybook configuration automatically detects the environment:
|
|
||||||
|
|
||||||
- **Local development**: No base path, relative assets
|
|
||||||
- **CI/Production**: Base path `/communityrulestorybook/` for GitHub Pages
|
|
||||||
|
|
||||||
## 📋 Available Scripts
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
- `npm run dev` - Start Next.js development server
|
|
||||||
- `npm run build` - Build Next.js application for production
|
|
||||||
- `npm run start` - Start Next.js production server
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
- `npm test` - Run all component tests with coverage
|
|
||||||
- `npm run test:component` - Run tests in `tests/components/` only
|
|
||||||
- `npm run test:watch` - Run tests in watch mode
|
|
||||||
- `npm run test:ui` - Run tests with UI
|
|
||||||
- `npm run test:e2e` - Run E2E tests only
|
|
||||||
- `npm run e2e` - Alias for Playwright E2E tests
|
|
||||||
- `npm run e2e:ui` - Run E2E tests with UI
|
|
||||||
- `npm run e2e:serve` - Start dev server and run E2E tests
|
|
||||||
- `npm run lhci` - Run performance tests
|
|
||||||
|
|
||||||
### Storybook
|
|
||||||
|
|
||||||
- `npm run storybook:local` - Start Storybook for local development
|
|
||||||
- `npm run storybook:github` - Start Storybook with GitHub Pages configuration
|
|
||||||
- `npm run storybook:build` - Build Storybook for local deployment
|
|
||||||
- `npm run storybook:build:github` - Build Storybook for GitHub Pages
|
|
||||||
- `npm run storybook` - Start Storybook with current configuration
|
|
||||||
|
|
||||||
## 🏗️ Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
community-rule/
|
|
||||||
├── app/ # Next.js app directory
|
|
||||||
│ ├── components/ # React components
|
|
||||||
│ ├── hooks/ # Custom React hooks
|
|
||||||
│ ├── layout.tsx # Root layout
|
|
||||||
│ └── page.tsx # Homepage
|
|
||||||
├── config/ # Project-specific configuration
|
|
||||||
│ ├── gitea-runner.yaml # Gitea runner configuration
|
|
||||||
│ └── runner-config.yaml # Runner configuration
|
|
||||||
├── docs/ # Documentation
|
|
||||||
│ ├── README.md # Documentation index
|
|
||||||
│ ├── TESTING_GUIDE.md # Testing guide
|
|
||||||
│ ├── CUSTOM_HOOKS.md # Custom hooks documentation
|
|
||||||
│ └── guides/ # Guides
|
|
||||||
│ └── content-creation.md # Content creation guide
|
|
||||||
├── scripts/ # Utility scripts
|
|
||||||
│ ├── start-runner.sh # Start Gitea runner
|
|
||||||
│ ├── status-runner.sh # Check runner status
|
|
||||||
│ └── stop-runner.sh # Stop Gitea runner
|
|
||||||
├── tests/ # Test files
|
|
||||||
│ ├── components/ # Component tests (Vitest + RTL)
|
|
||||||
│ ├── pages/ # Page-level tests
|
|
||||||
│ ├── e2e/ # E2E tests (Playwright)
|
|
||||||
│ ├── utils/ # Test utilities (componentTestSuite, etc.)
|
|
||||||
│ ├── msw/ # MSW server setup
|
|
||||||
│ └── accessibility/ # E2E accessibility checks
|
|
||||||
├── .storybook/ # Storybook configuration
|
|
||||||
├── .gitea/ # Gitea Actions workflows
|
|
||||||
│ └── workflows/
|
|
||||||
│ └── ci.yaml # CI/CD pipeline
|
|
||||||
└── public/ # Static assets
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Technology Stack
|
|
||||||
|
|
||||||
- **Framework**: Next.js 16 + React 19
|
|
||||||
- **Runtime**: Node.js 20+ (LTS)
|
|
||||||
- **Styling**: Tailwind CSS 4
|
|
||||||
- **Testing**: Vitest + Playwright + Lighthouse CI
|
|
||||||
- **Documentation**: Storybook 10
|
|
||||||
- **CI/CD**: Gitea Actions
|
|
||||||
- **Hosting**: Gitea (Git hosting)
|
|
||||||
|
|
||||||
## 📖 Documentation
|
|
||||||
|
|
||||||
- **[Documentation Index](docs/README.md)** - Complete documentation guide
|
|
||||||
- **[Testing Guide](docs/TESTING_GUIDE.md)** - Testing strategy, component tests, E2E tests, and accessibility
|
|
||||||
- **[Custom Hooks](docs/CUSTOM_HOOKS.md)** - Documentation for custom React hooks
|
|
||||||
- **[Content Creation Guide](docs/guides/content-creation.md)** - Guide for creating blog content
|
|
||||||
- **[Storybook](http://localhost:6006)** - Component documentation (local)
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
1. **Fork the repository**
|
|
||||||
2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
|
|
||||||
3. **Write tests first** (see [Testing Guide](docs/TESTING_GUIDE.md))
|
|
||||||
4. **Make your changes**
|
|
||||||
5. **Run tests**: `npm test && npm run e2e`
|
|
||||||
6. **Commit changes**: `git commit -m "feat: add amazing feature"`
|
|
||||||
7. **Push to branch**: `git push origin feature/amazing-feature`
|
|
||||||
8. **Create Pull Request**
|
|
||||||
|
|
||||||
### Development Workflow
|
|
||||||
|
|
||||||
- All changes must have tests
|
|
||||||
- CI pipeline runs automatically on PRs
|
|
||||||
- Visual regression tests ensure UI consistency
|
|
||||||
- Performance budgets must be met
|
|
||||||
- Accessibility standards must be maintained
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Operator/admin dashboards (e.g. `/monitor`) intentionally render without the
|
||||||
|
// public marketing footer. Auth/access is enforced upstream.
|
||||||
|
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <main className="flex-1">{children}</main>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import WebVitalsDashboard from "../../components/WebVitalsDashboard";
|
import WebVitalsDashboard from "../../components/sections/WebVitalsDashboard";
|
||||||
import TopNav from "../../components/navigation/TopNav";
|
import TopNav from "../../components/navigation/TopNav";
|
||||||
import Footer from "../../components/navigation/Footer";
|
import Footer from "../../components/navigation/Footer";
|
||||||
|
|
||||||
|
|||||||
+13
-13
@@ -11,35 +11,35 @@ import { usePathname, useRouter } from "next/navigation";
|
|||||||
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
|
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
|
||||||
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
|
||||||
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
|
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
|
||||||
import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
|
import CreateFlowTopNav from "../../components/utility/CreateFlowTopNav";
|
||||||
import { getNextStep, getStepIndex } from "./utils/flowSteps";
|
import { getNextStep, getStepIndex } from "./utils/flowSteps";
|
||||||
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
|
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
|
||||||
import {
|
import {
|
||||||
createFlowStepUsesCenteredTextLayout,
|
createFlowStepUsesCenteredTextLayout,
|
||||||
createFlowStepUsesCardLayout,
|
createFlowStepUsesCardLayout,
|
||||||
} from "./utils/createFlowScreenRegistry";
|
} from "./utils/createFlowScreenRegistry";
|
||||||
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
|
import CreateFlowFooter from "../../components/utility/CreateFlowFooter";
|
||||||
import Button from "../components/buttons/Button";
|
import Button from "../../components/buttons/Button";
|
||||||
import { buildPublishPayload } from "../../lib/create/buildPublishPayload";
|
import { buildPublishPayload } from "../../../lib/create/buildPublishPayload";
|
||||||
import { isValidCreateFlowSaveEmail } from "../../lib/create/isValidCreateFlowSaveEmail";
|
import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail";
|
||||||
import {
|
import {
|
||||||
fetchAuthSession,
|
fetchAuthSession,
|
||||||
publishRule,
|
publishRule,
|
||||||
requestMagicLink,
|
requestMagicLink,
|
||||||
} from "../../lib/create/api";
|
} from "../../../lib/create/api";
|
||||||
import { safeInternalPath } from "../../lib/safeInternalPath";
|
import { safeInternalPath } from "../../../lib/safeInternalPath";
|
||||||
import { setTransferPendingFlag } from "./utils/anonymousDraftStorage";
|
import { setTransferPendingFlag } from "./utils/anonymousDraftStorage";
|
||||||
import { writeLastPublishedRule } from "../../lib/create/lastPublishedRule";
|
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
|
||||||
import {
|
import {
|
||||||
fetchTemplateBySlug,
|
fetchTemplateBySlug,
|
||||||
type RuleTemplateDto,
|
type RuleTemplateDto,
|
||||||
} from "../../lib/create/fetchTemplates";
|
} from "../../../lib/create/fetchTemplates";
|
||||||
import messages from "../../messages/en/index";
|
import messages from "../../../messages/en/index";
|
||||||
import { useAuthModal } from "../contexts/AuthModalContext";
|
import { useAuthModal } from "../../contexts/AuthModalContext";
|
||||||
import { useMessages, useTranslation } from "../contexts/MessagesContext";
|
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
|
||||||
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
|
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
|
||||||
import { SignedInDraftHydration } from "./SignedInDraftHydration";
|
import { SignedInDraftHydration } from "./SignedInDraftHydration";
|
||||||
import Alert from "../components/modals/Alert";
|
import Alert from "../../components/modals/Alert";
|
||||||
import {
|
import {
|
||||||
CreateFlowDraftSaveBannerProvider,
|
CreateFlowDraftSaveBannerProvider,
|
||||||
useCreateFlowDraftSaveBanner,
|
useCreateFlowDraftSaveBanner,
|
||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
} from "./utils/anonymousDraftStorage";
|
} from "./utils/anonymousDraftStorage";
|
||||||
import { useCreateFlow } from "./context/CreateFlowContext";
|
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||||
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
|
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
|
||||||
import { saveDraftToServer } from "../../lib/create/api";
|
import { saveDraftToServer } from "../../../lib/create/api";
|
||||||
import messages from "../../messages/en/index";
|
import messages from "../../../messages/en/index";
|
||||||
|
|
||||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||||
|
|
||||||
@@ -3,15 +3,15 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import type { CreateFlowState } from "./types";
|
import type { CreateFlowState } from "./types";
|
||||||
import { createFlowStateHasKeys } from "../../lib/create/draftHydrationUtils";
|
import { createFlowStateHasKeys } from "../../../lib/create/draftHydrationUtils";
|
||||||
import {
|
import {
|
||||||
clearAnonymousCreateFlowStorage,
|
clearAnonymousCreateFlowStorage,
|
||||||
hasTransferPendingFlag,
|
hasTransferPendingFlag,
|
||||||
readAnonymousCreateFlowState,
|
readAnonymousCreateFlowState,
|
||||||
} from "./utils/anonymousDraftStorage";
|
} from "./utils/anonymousDraftStorage";
|
||||||
import { useCreateFlow } from "./context/CreateFlowContext";
|
import { useCreateFlow } from "./context/CreateFlowContext";
|
||||||
import { fetchDraftFromServer } from "../../lib/create/api";
|
import { fetchDraftFromServer } from "../../../lib/create/api";
|
||||||
import messages from "../../messages/en/index";
|
import messages from "../../../messages/en/index";
|
||||||
|
|
||||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||||
|
|
||||||
+5
-5
@@ -8,8 +8,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { memo, useState } from "react";
|
import { memo, useState } from "react";
|
||||||
import Chip from "../../components/controls/Chip";
|
import Chip from "../../../components/controls/Chip";
|
||||||
import InputLabel from "../../components/utility/InputLabel";
|
import InputLabel from "../../../components/utility/InputLabel";
|
||||||
|
|
||||||
export interface ApplicableScopeFieldProps {
|
export interface ApplicableScopeFieldProps {
|
||||||
/** Label rendered above the capsule row. */
|
/** Label rendered above the capsule row. */
|
||||||
@@ -74,9 +74,9 @@ function ApplicableScopeFieldComponent({
|
|||||||
<Chip
|
<Chip
|
||||||
key={scope}
|
key={scope}
|
||||||
label={scope}
|
label={scope}
|
||||||
state={isSelected ? "Selected" : "Disabled"}
|
state={isSelected ? "selected" : "disabled"}
|
||||||
palette="Default"
|
palette="default"
|
||||||
size="S"
|
size="s"
|
||||||
disabled={false}
|
disabled={false}
|
||||||
onClick={() => onToggleScope(scope)}
|
onClick={() => onToggleScope(scope)}
|
||||||
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
|
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import HeaderLockup from "../../components/type/HeaderLockup";
|
import HeaderLockup from "../../../components/type/HeaderLockup";
|
||||||
import type { HeaderLockupProps } from "../../components/type/HeaderLockup/HeaderLockup.types";
|
import type { HeaderLockupProps } from "../../../components/type/HeaderLockup/HeaderLockup.types";
|
||||||
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
|
||||||
|
|
||||||
export type CreateFlowHeaderLockupProps = Omit<HeaderLockupProps, "size"> & {
|
export type CreateFlowHeaderLockupProps = Omit<HeaderLockupProps, "size"> & {
|
||||||
+2
-2
@@ -7,8 +7,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { memo, useId } from "react";
|
import { memo, useId } from "react";
|
||||||
import TextArea from "../../components/controls/TextArea";
|
import TextArea from "../../../components/controls/TextArea";
|
||||||
import InputLabel from "../../components/utility/InputLabel";
|
import InputLabel from "../../../components/utility/InputLabel";
|
||||||
|
|
||||||
export interface ModalTextAreaFieldProps {
|
export interface ModalTextAreaFieldProps {
|
||||||
/** Label rendered above the text area. */
|
/** Label rendered above the text area. */
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import type { CreateFlowState, CreateFlowStep } from "../types";
|
import type { CreateFlowState, CreateFlowStep } from "../types";
|
||||||
import { saveDraftToServer } from "../../../lib/create/api";
|
import { saveDraftToServer } from "../../../../lib/create/api";
|
||||||
import messages from "../../../messages/en/index";
|
import messages from "../../../../messages/en/index";
|
||||||
|
|
||||||
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
import { useMediaQuery } from "../../../hooks/useMediaQuery";
|
||||||
|
|
||||||
/** `--breakpoint-lg` (1024px); same SSR/first-paint pattern as `useCreateFlowMdUp`. */
|
/** `--breakpoint-lg` (1024px); same SSR/first-paint pattern as `useCreateFlowMdUp`. */
|
||||||
const CREATE_FLOW_MIN_WIDTH_LG = "(min-width: 1024px)";
|
const CREATE_FLOW_MIN_WIDTH_LG = "(min-width: 1024px)";
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMediaQuery } from "../../hooks/useMediaQuery";
|
import { useMediaQuery } from "../../../hooks/useMediaQuery";
|
||||||
|
|
||||||
/** `--breakpoint-md` (640px); same SSR/first-paint pattern as `useCreateFlowLgUp`. */
|
/** `--breakpoint-md` (640px); same SSR/first-paint pattern as `useCreateFlowLgUp`. */
|
||||||
const CREATE_FLOW_MIN_WIDTH_MD = "(min-width: 640px)";
|
const CREATE_FLOW_MIN_WIDTH_MD = "(min-width: 640px)";
|
||||||
+5
-5
@@ -1,15 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { use, useEffect, useState } from "react";
|
import { use, useEffect, useState } from "react";
|
||||||
import { TemplateReviewCard } from "../../../components/cards/TemplateReviewCard";
|
import { TemplateReviewCard } from "../../../../components/cards/TemplateReviewCard";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
import {
|
import {
|
||||||
fetchTemplateBySlug,
|
fetchTemplateBySlug,
|
||||||
isTemplatesFetchAborted,
|
isTemplatesFetchAborted,
|
||||||
type RuleTemplateDto,
|
type RuleTemplateDto,
|
||||||
} from "../../../../lib/create/fetchTemplates";
|
} from "../../../../../lib/create/fetchTemplates";
|
||||||
import messages from "../../../../messages/en/index";
|
import messages from "../../../../../messages/en/index";
|
||||||
import Alert from "../../../components/modals/Alert";
|
import Alert from "../../../../components/modals/Alert";
|
||||||
import {
|
import {
|
||||||
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
|
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
|
||||||
CreateFlowLockupCardStepShell,
|
CreateFlowLockupCardStepShell,
|
||||||
+4
-4
@@ -10,13 +10,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import { useMessages } from "../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import CardStack from "../../../components/utility/CardStack";
|
import CardStack from "../../../../components/utility/CardStack";
|
||||||
import Create from "../../../components/modals/Create";
|
import Create from "../../../../components/modals/Create";
|
||||||
import InlineTextButton from "../../../components/buttons/InlineTextButton";
|
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||||
import {
|
import {
|
||||||
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||||
+4
-4
@@ -12,13 +12,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import { useMessages } from "../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import CardStack from "../../../components/utility/CardStack";
|
import CardStack from "../../../../components/utility/CardStack";
|
||||||
import Create from "../../../components/modals/Create";
|
import Create from "../../../../components/modals/Create";
|
||||||
import InlineTextButton from "../../../components/buttons/InlineTextButton";
|
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||||
import {
|
import {
|
||||||
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||||
+4
-4
@@ -12,13 +12,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import { useMessages } from "../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import CardStack from "../../../components/utility/CardStack";
|
import CardStack from "../../../../components/utility/CardStack";
|
||||||
import Create from "../../../components/modals/Create";
|
import Create from "../../../../components/modals/Create";
|
||||||
import InlineTextButton from "../../../components/buttons/InlineTextButton";
|
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||||
import {
|
import {
|
||||||
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
|
||||||
+6
-6
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import CommunityRuleDocument from "../../../components/sections/CommunityRuleDocument";
|
import CommunityRuleDocument from "../../../../components/sections/CommunityRuleDocument";
|
||||||
import type { CommunityRuleDocumentSection } from "../../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
|
import type { CommunityRuleDocumentSection } from "../../../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
|
||||||
import Alert from "../../../components/modals/Alert";
|
import Alert from "../../../../components/modals/Alert";
|
||||||
import { useMessages } from "../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { parseDocumentSectionsForDisplay } from "../../../../lib/create/buildPublishPayload";
|
import { parseDocumentSectionsForDisplay } from "../../../../../lib/create/buildPublishPayload";
|
||||||
import { readLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
|
import { readLastPublishedRule } from "../../../../../lib/create/lastPublishedRule";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import {
|
import {
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import NumberedList from "../../../components/type/NumberedList";
|
import NumberedList from "../../../../components/type/NumberedList";
|
||||||
import { useMessages } from "../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import RuleCard from "../../../components/cards/RuleCard";
|
import RuleCard from "../../../../components/cards/RuleCard";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useCreateFlowLgUp } from "../../hooks/useCreateFlowLgUp";
|
import { useCreateFlowLgUp } from "../../hooks/useCreateFlowLgUp";
|
||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import RuleCard from "../../../components/cards/RuleCard";
|
import RuleCard from "../../../../components/cards/RuleCard";
|
||||||
import type { Category } from "../../../components/cards/RuleCard/RuleCard.types";
|
import type { Category } from "../../../../components/cards/RuleCard/RuleCard.types";
|
||||||
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
|
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
import {
|
import {
|
||||||
+8
-8
@@ -14,14 +14,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
|
import DecisionMakingSidebar from "../../../../components/utility/DecisionMakingSidebar";
|
||||||
import CardStack from "../../../components/utility/CardStack";
|
import CardStack from "../../../../components/utility/CardStack";
|
||||||
import Create from "../../../components/modals/Create";
|
import Create from "../../../../components/modals/Create";
|
||||||
import IncrementerBlock from "../../../components/controls/IncrementerBlock";
|
import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
|
||||||
import InlineTextButton from "../../../components/buttons/InlineTextButton";
|
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
|
||||||
import type { InfoMessageBoxItem } from "../../../components/utility/InfoMessageBox/InfoMessageBox.types";
|
import type { InfoMessageBoxItem } from "../../../../components/utility/InfoMessageBox/InfoMessageBox.types";
|
||||||
import type { CardStackItem } from "../../../components/utility/CardStack/CardStack.types";
|
import type { CardStackItem } from "../../../../components/utility/CardStack/CardStack.types";
|
||||||
import { useMessages } from "../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||||
+13
-13
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import MultiSelect from "../../../components/controls/MultiSelect";
|
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||||
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
|
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||||
import { useMessages } from "../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
|
||||||
@@ -14,13 +14,13 @@ function chipRowsFromLabels(
|
|||||||
return rows.map((row, i) => ({
|
return rows.map((row, i) => ({
|
||||||
id: String(i + 1),
|
id: String(i + 1),
|
||||||
label: row.label,
|
label: row.label,
|
||||||
state: "Unselected" as const,
|
state: "unselected" as const,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
||||||
return options
|
return options
|
||||||
.filter((o) => o.state === "Selected")
|
.filter((o) => o.state === "selected")
|
||||||
.map((o) => o.id);
|
.map((o) => o.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ export function CommunitySizeSelectScreen() {
|
|||||||
const selected = new Set(state.selectedCommunitySizeIds ?? []);
|
const selected = new Set(state.selectedCommunitySizeIds ?? []);
|
||||||
return base.map((opt) => ({
|
return base.map((opt) => ({
|
||||||
...opt,
|
...opt,
|
||||||
state: selected.has(opt.id) ? ("Selected" as const) : ("Unselected" as const),
|
state: selected.has(opt.id) ? ("selected" as const) : ("unselected" as const),
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,13 +45,13 @@ export function CommunitySizeSelectScreen() {
|
|||||||
const selected = new Set(state.selectedCommunitySizeIds ?? []);
|
const selected = new Set(state.selectedCommunitySizeIds ?? []);
|
||||||
setCommunitySizeOptions((prev) =>
|
setCommunitySizeOptions((prev) =>
|
||||||
prev.map((opt) =>
|
prev.map((opt) =>
|
||||||
opt.state === "Custom"
|
opt.state === "custom"
|
||||||
? opt
|
? opt
|
||||||
: {
|
: {
|
||||||
...opt,
|
...opt,
|
||||||
state: selected.has(opt.id)
|
state: selected.has(opt.id)
|
||||||
? ("Selected" as const)
|
? ("selected" as const)
|
||||||
: ("Unselected" as const),
|
: ("unselected" as const),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -71,9 +71,9 @@ export function CommunitySizeSelectScreen() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? ("Unselected" as const)
|
? ("unselected" as const)
|
||||||
: ("Selected" as const),
|
: ("selected" as const),
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
);
|
);
|
||||||
@@ -83,7 +83,7 @@ export function CommunitySizeSelectScreen() {
|
|||||||
const multiSelectBlock = (
|
const multiSelectBlock = (
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
size="M"
|
size="m"
|
||||||
options={communitySizeOptions}
|
options={communitySizeOptions}
|
||||||
onChipClick={handleCommunitySizeClick}
|
onChipClick={handleCommunitySizeClick}
|
||||||
addButton={false}
|
addButton={false}
|
||||||
+25
-25
@@ -7,9 +7,9 @@ import {
|
|||||||
type Dispatch,
|
type Dispatch,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
} from "react";
|
} from "react";
|
||||||
import MultiSelect from "../../../components/controls/MultiSelect";
|
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||||
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
|
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||||
import { useMessages } from "../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import type { CommunityStructureChipSnapshotRow } from "../../types";
|
import type { CommunityStructureChipSnapshotRow } from "../../types";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
@@ -17,7 +17,7 @@ import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoCo
|
|||||||
|
|
||||||
function createListCustomHandlers(
|
function createListCustomHandlers(
|
||||||
setList: Dispatch<SetStateAction<ChipOption[]>>,
|
setList: Dispatch<SetStateAction<ChipOption[]>>,
|
||||||
confirmState: "Unselected" | "Selected",
|
confirmState: "unselected" | "selected",
|
||||||
onInteraction?: () => void,
|
onInteraction?: () => void,
|
||||||
) {
|
) {
|
||||||
const touch = () => onInteraction?.();
|
const touch = () => onInteraction?.();
|
||||||
@@ -26,7 +26,7 @@ function createListCustomHandlers(
|
|||||||
touch();
|
touch();
|
||||||
setList((prev) => [
|
setList((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ id: crypto.randomUUID(), label: "", state: "Custom" },
|
{ id: crypto.randomUUID(), label: "", state: "custom" },
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
onCustomChipConfirm: (chipId: string, value: string) => {
|
onCustomChipConfirm: (chipId: string, value: string) => {
|
||||||
@@ -52,7 +52,7 @@ function chipRowsFromLabels(
|
|||||||
return rows.map((row, i) => ({
|
return rows.map((row, i) => ({
|
||||||
id: String(i + 1),
|
id: String(i + 1),
|
||||||
label: row.label,
|
label: row.label,
|
||||||
state: "Unselected" as const,
|
state: "unselected" as const,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,20 +62,20 @@ function applySavedSelection(
|
|||||||
): ChipOption[] {
|
): ChipOption[] {
|
||||||
const selected = new Set(saved ?? []);
|
const selected = new Set(saved ?? []);
|
||||||
return options.map((opt) =>
|
return options.map((opt) =>
|
||||||
opt.state === "Custom"
|
opt.state === "custom"
|
||||||
? opt
|
? opt
|
||||||
: {
|
: {
|
||||||
...opt,
|
...opt,
|
||||||
state: selected.has(opt.id)
|
state: selected.has(opt.id)
|
||||||
? ("Selected" as const)
|
? ("selected" as const)
|
||||||
: ("Unselected" as const),
|
: ("unselected" as const),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
||||||
return options
|
return options
|
||||||
.filter((o) => o.state === "Selected")
|
.filter((o) => o.state === "selected")
|
||||||
.map((o) => o.id);
|
.map((o) => o.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ export function CommunityStructureSelectScreen() {
|
|||||||
() =>
|
() =>
|
||||||
createListCustomHandlers(
|
createListCustomHandlers(
|
||||||
setOrganizationTypeOptions,
|
setOrganizationTypeOptions,
|
||||||
"Unselected",
|
"unselected",
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
),
|
),
|
||||||
[markCreateFlowInteraction],
|
[markCreateFlowInteraction],
|
||||||
@@ -203,7 +203,7 @@ export function CommunityStructureSelectScreen() {
|
|||||||
() =>
|
() =>
|
||||||
createListCustomHandlers(
|
createListCustomHandlers(
|
||||||
setScaleOptions,
|
setScaleOptions,
|
||||||
"Unselected",
|
"unselected",
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
),
|
),
|
||||||
[markCreateFlowInteraction],
|
[markCreateFlowInteraction],
|
||||||
@@ -212,7 +212,7 @@ export function CommunityStructureSelectScreen() {
|
|||||||
() =>
|
() =>
|
||||||
createListCustomHandlers(
|
createListCustomHandlers(
|
||||||
setMaturityOptions,
|
setMaturityOptions,
|
||||||
"Unselected",
|
"unselected",
|
||||||
markCreateFlowInteraction,
|
markCreateFlowInteraction,
|
||||||
),
|
),
|
||||||
[markCreateFlowInteraction],
|
[markCreateFlowInteraction],
|
||||||
@@ -258,9 +258,9 @@ export function CommunityStructureSelectScreen() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? ("Unselected" as const)
|
? ("unselected" as const)
|
||||||
: ("Selected" as const),
|
: ("selected" as const),
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -274,9 +274,9 @@ export function CommunityStructureSelectScreen() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? ("Unselected" as const)
|
? ("unselected" as const)
|
||||||
: ("Selected" as const),
|
: ("selected" as const),
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -290,9 +290,9 @@ export function CommunityStructureSelectScreen() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? ("Unselected" as const)
|
? ("unselected" as const)
|
||||||
: ("Selected" as const),
|
: ("selected" as const),
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -304,7 +304,7 @@ export function CommunityStructureSelectScreen() {
|
|||||||
<MultiSelect
|
<MultiSelect
|
||||||
label={cs.organizationMultiSelect.label}
|
label={cs.organizationMultiSelect.label}
|
||||||
showHelpIcon
|
showHelpIcon
|
||||||
size="S"
|
size="s"
|
||||||
options={organizationTypeOptions}
|
options={organizationTypeOptions}
|
||||||
onChipClick={handleOrganizationTypeClick}
|
onChipClick={handleOrganizationTypeClick}
|
||||||
{...organizationCustomHandlers}
|
{...organizationCustomHandlers}
|
||||||
@@ -314,7 +314,7 @@ export function CommunityStructureSelectScreen() {
|
|||||||
<MultiSelect
|
<MultiSelect
|
||||||
label={cs.scaleMultiSelect.label}
|
label={cs.scaleMultiSelect.label}
|
||||||
showHelpIcon
|
showHelpIcon
|
||||||
size="S"
|
size="s"
|
||||||
options={scaleOptions}
|
options={scaleOptions}
|
||||||
onChipClick={handleScaleClick}
|
onChipClick={handleScaleClick}
|
||||||
{...scaleCustomHandlers}
|
{...scaleCustomHandlers}
|
||||||
@@ -324,7 +324,7 @@ export function CommunityStructureSelectScreen() {
|
|||||||
<MultiSelect
|
<MultiSelect
|
||||||
label={cs.maturityMultiSelect.label}
|
label={cs.maturityMultiSelect.label}
|
||||||
showHelpIcon
|
showHelpIcon
|
||||||
size="S"
|
size="s"
|
||||||
options={maturityOptions}
|
options={maturityOptions}
|
||||||
onChipClick={handleMaturityClick}
|
onChipClick={handleMaturityClick}
|
||||||
{...maturityCustomHandlers}
|
{...maturityCustomHandlers}
|
||||||
+7
-7
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import MultiSelect from "../../../components/controls/MultiSelect";
|
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||||
import Alert from "../../../components/modals/Alert";
|
import Alert from "../../../../components/modals/Alert";
|
||||||
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
|
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||||
@@ -22,7 +22,7 @@ export function ConfirmStakeholdersScreen() {
|
|||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
setStakeholderOptions((prev) => [
|
setStakeholderOptions((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{ id: crypto.randomUUID(), label: "", state: "Custom" },
|
{ id: crypto.randomUUID(), label: "", state: "custom" },
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ export function ConfirmStakeholdersScreen() {
|
|||||||
markCreateFlowInteraction();
|
markCreateFlowInteraction();
|
||||||
setStakeholderOptions((prev) =>
|
setStakeholderOptions((prev) =>
|
||||||
prev.map((opt) =>
|
prev.map((opt) =>
|
||||||
opt.id === chipId ? { ...opt, label: value, state: "Selected" } : opt,
|
opt.id === chipId ? { ...opt, label: value, state: "selected" } : opt,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -64,7 +64,7 @@ export function ConfirmStakeholdersScreen() {
|
|||||||
<MultiSelect
|
<MultiSelect
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
showHelpIcon={false}
|
showHelpIcon={false}
|
||||||
size="S"
|
size="s"
|
||||||
options={stakeholderOptions}
|
options={stakeholderOptions}
|
||||||
onChipClick={handleChipClick}
|
onChipClick={handleChipClick}
|
||||||
onAddClick={handleAddStakeholder}
|
onAddClick={handleAddStakeholder}
|
||||||
+22
-22
@@ -1,12 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import MultiSelect from "../../../components/controls/MultiSelect";
|
import MultiSelect from "../../../../components/controls/MultiSelect";
|
||||||
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
|
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
|
||||||
import TextArea from "../../../components/controls/TextArea";
|
import TextArea from "../../../../components/controls/TextArea";
|
||||||
import Create from "../../../components/modals/Create";
|
import Create from "../../../../components/modals/Create";
|
||||||
import ContentLockup from "../../../components/type/ContentLockup";
|
import ContentLockup from "../../../../components/type/ContentLockup";
|
||||||
import { useMessages } from "../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import type { CommunityStructureChipSnapshotRow } from "../../types";
|
import type { CommunityStructureChipSnapshotRow } from "../../types";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
@@ -46,7 +46,7 @@ function chipRowsFromPresets(presets: readonly CoreValuePreset[]): ChipOption[]
|
|||||||
return presets.map((row, i) => ({
|
return presets.map((row, i) => ({
|
||||||
id: String(i + 1),
|
id: String(i + 1),
|
||||||
label: row.label,
|
label: row.label,
|
||||||
state: "Unselected" as const,
|
state: "unselected" as const,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,20 +56,20 @@ function applySavedSelection(
|
|||||||
): ChipOption[] {
|
): ChipOption[] {
|
||||||
const selected = new Set(saved ?? []);
|
const selected = new Set(saved ?? []);
|
||||||
return options.map((opt) =>
|
return options.map((opt) =>
|
||||||
opt.state === "Custom"
|
opt.state === "custom"
|
||||||
? opt
|
? opt
|
||||||
: {
|
: {
|
||||||
...opt,
|
...opt,
|
||||||
state: selected.has(opt.id)
|
state: selected.has(opt.id)
|
||||||
? ("Selected" as const)
|
? ("selected" as const)
|
||||||
: ("Unselected" as const),
|
: ("unselected" as const),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
function selectedIdsFromOptions(options: ChipOption[]): string[] {
|
||||||
return options
|
return options
|
||||||
.filter((o) => o.state === "Selected")
|
.filter((o) => o.state === "selected")
|
||||||
.map((o) => o.id);
|
.map((o) => o.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ export function CoreValuesSelectScreen() {
|
|||||||
if (activeModalChipId && modalSession === "pending") {
|
if (activeModalChipId && modalSession === "pending") {
|
||||||
const next = coreValueOptions.map((opt) =>
|
const next = coreValueOptions.map((opt) =>
|
||||||
opt.id === activeModalChipId
|
opt.id === activeModalChipId
|
||||||
? { ...opt, state: "Unselected" as const }
|
? { ...opt, state: "unselected" as const }
|
||||||
: opt,
|
: opt,
|
||||||
);
|
);
|
||||||
persistCoreValues(next);
|
persistCoreValues(next);
|
||||||
@@ -226,16 +226,16 @@ export function CoreValuesSelectScreen() {
|
|||||||
|
|
||||||
const handleChipClick = (chipId: string) => {
|
const handleChipClick = (chipId: string) => {
|
||||||
const target = coreValueOptions.find((o) => o.id === chipId);
|
const target = coreValueOptions.find((o) => o.id === chipId);
|
||||||
if (!target || target.state === "Custom") return;
|
if (!target || target.state === "custom") return;
|
||||||
|
|
||||||
const selectedCount = coreValueOptions.filter(
|
const selectedCount = coreValueOptions.filter(
|
||||||
(o) => o.state === "Selected",
|
(o) => o.state === "selected",
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
if (target.state === "Selected") {
|
if (target.state === "selected") {
|
||||||
const next: ChipOption[] = coreValueOptions.map((opt) =>
|
const next: ChipOption[] = coreValueOptions.map((opt) =>
|
||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? { ...opt, state: "Unselected" as const }
|
? { ...opt, state: "unselected" as const }
|
||||||
: opt,
|
: opt,
|
||||||
);
|
);
|
||||||
persistCoreValues(next);
|
persistCoreValues(next);
|
||||||
@@ -246,7 +246,7 @@ export function CoreValuesSelectScreen() {
|
|||||||
|
|
||||||
const next: ChipOption[] = coreValueOptions.map((opt) =>
|
const next: ChipOption[] = coreValueOptions.map((opt) =>
|
||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? { ...opt, state: "Selected" as const }
|
? { ...opt, state: "selected" as const }
|
||||||
: opt,
|
: opt,
|
||||||
);
|
);
|
||||||
persistCoreValues(next);
|
persistCoreValues(next);
|
||||||
@@ -259,7 +259,7 @@ export function CoreValuesSelectScreen() {
|
|||||||
setCoreValueOptions((prev) => {
|
setCoreValueOptions((prev) => {
|
||||||
const next: ChipOption[] = [
|
const next: ChipOption[] = [
|
||||||
...prev,
|
...prev,
|
||||||
{ id: crypto.randomUUID(), label: "", state: "Custom" },
|
{ id: crypto.randomUUID(), label: "", state: "custom" },
|
||||||
];
|
];
|
||||||
queueMicrotask(() => syncCoreValuesToDraft(next));
|
queueMicrotask(() => syncCoreValuesToDraft(next));
|
||||||
return next;
|
return next;
|
||||||
@@ -270,17 +270,17 @@ export function CoreValuesSelectScreen() {
|
|||||||
setCoreValueOptions((prev) => {
|
setCoreValueOptions((prev) => {
|
||||||
const withLabel = prev.map((opt) =>
|
const withLabel = prev.map((opt) =>
|
||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? { ...opt, label: value, state: "Unselected" as const }
|
? { ...opt, label: value, state: "unselected" as const }
|
||||||
: opt,
|
: opt,
|
||||||
);
|
);
|
||||||
const selectedCount = withLabel.filter(
|
const selectedCount = withLabel.filter(
|
||||||
(o) => o.state === "Selected",
|
(o) => o.state === "selected",
|
||||||
).length;
|
).length;
|
||||||
const canSelect = selectedCount < MAX_CORE_VALUES;
|
const canSelect = selectedCount < MAX_CORE_VALUES;
|
||||||
const next = canSelect
|
const next = canSelect
|
||||||
? withLabel.map((opt) =>
|
? withLabel.map((opt) =>
|
||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? { ...opt, state: "Selected" as const }
|
? { ...opt, state: "selected" as const }
|
||||||
: opt,
|
: opt,
|
||||||
)
|
)
|
||||||
: withLabel;
|
: withLabel;
|
||||||
@@ -343,7 +343,7 @@ export function CoreValuesSelectScreen() {
|
|||||||
>
|
>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
formHeader={false}
|
formHeader={false}
|
||||||
size="M"
|
size="m"
|
||||||
options={coreValueOptions}
|
options={coreValueOptions}
|
||||||
onChipClick={handleChipClick}
|
onChipClick={handleChipClick}
|
||||||
onAddClick={addHandlers.onAddClick}
|
onAddClick={addHandlers.onAddClick}
|
||||||
+3
-3
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
|
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
|
||||||
import TextInput from "../../../components/controls/TextInput";
|
import TextInput from "../../../../components/controls/TextInput";
|
||||||
import type { HeaderLockupJustificationValue } from "../../../components/type/HeaderLockup/HeaderLockup.types";
|
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
|
||||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
import { useTranslation } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Upload from "../../../components/controls/Upload";
|
import Upload from "../../../../components/controls/Upload";
|
||||||
import { useMessages } from "../../../contexts/MessagesContext";
|
import { useMessages } from "../../../../contexts/MessagesContext";
|
||||||
import { useCreateFlow } from "../../context/CreateFlowContext";
|
import { useCreateFlow } from "../../context/CreateFlowContext";
|
||||||
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
|
||||||
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
|
||||||
+1
-10
@@ -1,5 +1,5 @@
|
|||||||
import type { CreateFlowState } from "../types";
|
import type { CreateFlowState } from "../types";
|
||||||
import { migrateLegacyCreateFlowState } from "../../../lib/create/migrateLegacyCreateFlowState";
|
import { migrateLegacyCreateFlowState } from "../../../../lib/create/migrateLegacyCreateFlowState";
|
||||||
|
|
||||||
/** Anonymous in-progress create flow (local only until magic-link transfer). */
|
/** Anonymous in-progress create flow (local only until magic-link transfer). */
|
||||||
export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const;
|
export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const;
|
||||||
@@ -75,15 +75,6 @@ export function hasTransferPendingFlag(): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearTransferPendingFlag(): void {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
try {
|
|
||||||
window.localStorage.removeItem(CREATE_FLOW_TRANSFER_PENDING_KEY);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One-time cleanup of pre–anonymous-draft keys. */
|
/** One-time cleanup of pre–anonymous-draft keys. */
|
||||||
export function clearLegacyCreateFlowKeysOnce(): void {
|
export function clearLegacyCreateFlowKeysOnce(): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { ProportionBarState } from "../../components/progress/ProportionBar/ProportionBar.types";
|
import type { ProportionBarState } from "../../../components/progress/ProportionBar/ProportionBar.types";
|
||||||
import type { CreateFlowStep } from "../types";
|
import type { CreateFlowStep } from "../types";
|
||||||
import { FLOW_STEP_ORDER, getStepIndex } from "./flowSteps";
|
import { FLOW_STEP_ORDER, getStepIndex } from "./flowSteps";
|
||||||
|
|
||||||
+3
-3
@@ -2,10 +2,10 @@ import type { CreateFlowStep } from "../types";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Figma layout families for the create flow (not encoded in the URL).
|
* Figma layout families for the create flow (not encoded in the URL).
|
||||||
* `app/create/screens/<kind>/` mirrors these names: e.g. `layoutKind: "select"` → `screens/select/`,
|
* `app/(app)/create/screens/<kind>/` mirrors these names: e.g. `layoutKind: "select"` → `screens/select/`,
|
||||||
* `"card"` → `screens/card/` (compact card-stack frames, distinct from two-column chip selects).
|
* `"card"` → `screens/card/` (compact card-stack frames, distinct from two-column chip selects).
|
||||||
*/
|
*/
|
||||||
export type CreateFlowLayoutKind =
|
type CreateFlowLayoutKind =
|
||||||
| "informational"
|
| "informational"
|
||||||
| "text"
|
| "text"
|
||||||
| "select"
|
| "select"
|
||||||
@@ -15,7 +15,7 @@ export type CreateFlowLayoutKind =
|
|||||||
| "right-rail"
|
| "right-rail"
|
||||||
| "completed";
|
| "completed";
|
||||||
|
|
||||||
export interface CreateFlowScreenDefinition {
|
interface CreateFlowScreenDefinition {
|
||||||
layoutKind: CreateFlowLayoutKind;
|
layoutKind: CreateFlowLayoutKind;
|
||||||
/** Figma node id (file Community-Rule-System), dev mode. */
|
/** Figma node id (file Community-Rule-System), dev mode. */
|
||||||
figmaNodeId: string;
|
figmaNodeId: string;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Signed-in product surfaces (`/create/*`, `/login`, `/profile`) intentionally
|
||||||
|
// run without the marketing footer. Per-route chrome (e.g. CreateFlow's own
|
||||||
|
// header/footer lockup) is composed in nested layouts.
|
||||||
|
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <main className="flex-1">{children}</main>;
|
||||||
|
}
|
||||||
@@ -3,18 +3,19 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslation } from "../contexts/MessagesContext";
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
import Login from "../components/modals/Login";
|
import Login from "../../components/modals/Login";
|
||||||
import LoginForm from "../components/modals/Login/LoginForm";
|
import LoginForm from "../../components/modals/Login/LoginForm";
|
||||||
|
|
||||||
const loginPageBgClass =
|
const loginPageBgClass =
|
||||||
"min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]";
|
"min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]";
|
||||||
|
|
||||||
function LoginLoadingFallback() {
|
function LoginLoadingFallback() {
|
||||||
|
const t = useTranslation("pages.login");
|
||||||
return (
|
return (
|
||||||
<div className={`${loginPageBgClass} flex items-center justify-center`}>
|
<div className={`${loginPageBgClass} flex items-center justify-center`}>
|
||||||
<p className="font-inter text-[14px] text-[var(--color-content-default-primary)]">
|
<p className="font-inter text-[14px] text-[var(--color-content-default-primary)]">
|
||||||
Loading…
|
{t("loadingFallback")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "../contexts/MessagesContext";
|
import { useTranslation } from "../../contexts/MessagesContext";
|
||||||
import Button from "../components/buttons/Button";
|
import Button from "../../components/buttons/Button";
|
||||||
import { fetchAuthSession, logout } from "../../lib/create/api";
|
import { fetchAuthSession, logout } from "../../../lib/create/api";
|
||||||
|
|
||||||
export default function ProfilePageClient() {
|
export default function ProfilePageClient() {
|
||||||
const t = useTranslation("pages.profile");
|
const t = useTranslation("pages.profile");
|
||||||
@@ -14,27 +14,26 @@ let ruleCardIdCounter = 0;
|
|||||||
interface ChipData {
|
interface ChipData {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
state: "Unselected" | "Selected" | "Custom";
|
state: "unselected" | "selected" | "custom";
|
||||||
palette: "Default" | "Inverse";
|
palette: "default" | "inverse";
|
||||||
size: "S" | "M";
|
size: "s" | "m";
|
||||||
}
|
}
|
||||||
|
|
||||||
// MultiSelect example component with state management
|
function MultiSelectExample({ size }: { size: "s" | "m" }) {
|
||||||
function MultiSelectExample({ size }: { size: "S" | "M" }) {
|
|
||||||
const [options, setOptions] = useState<
|
const [options, setOptions] = useState<
|
||||||
Array<{
|
Array<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
state: "Unselected" | "Selected" | "Custom";
|
state: "unselected" | "selected" | "custom";
|
||||||
}>
|
}>
|
||||||
>([
|
>([
|
||||||
{ id: "1", label: "1 member", state: "Unselected" },
|
{ id: "1", label: "1 member", state: "unselected" },
|
||||||
{ id: "2", label: "2-10 members", state: "Unselected" },
|
{ id: "2", label: "2-10 members", state: "unselected" },
|
||||||
{ id: "3", label: "10-24 members", state: "Unselected" },
|
{ id: "3", label: "10-24 members", state: "unselected" },
|
||||||
{ id: "4", label: "24-64 members", state: "Unselected" },
|
{ id: "4", label: "24-64 members", state: "unselected" },
|
||||||
{ id: "5", label: "64-128 members", state: "Unselected" },
|
{ id: "5", label: "64-128 members", state: "unselected" },
|
||||||
{ id: "6", label: "125-1000 members", state: "Unselected" },
|
{ id: "6", label: "125-1000 members", state: "unselected" },
|
||||||
{ id: "7", label: "1000+ members", state: "Unselected" },
|
{ id: "7", label: "1000+ members", state: "unselected" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleChipClick = (chipId: string) => {
|
const handleChipClick = (chipId: string) => {
|
||||||
@@ -43,7 +42,7 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
|
|||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state: opt.state === "Selected" ? "Unselected" : "Selected",
|
state: opt.state === "selected" ? "unselected" : "selected",
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -52,14 +51,14 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
|
|||||||
|
|
||||||
const handleAddClick = () => {
|
const handleAddClick = () => {
|
||||||
const newId = `custom-${Date.now()}`;
|
const newId = `custom-${Date.now()}`;
|
||||||
setOptions((prev) => [...prev, { id: newId, label: "", state: "Custom" }]);
|
setOptions((prev) => [...prev, { id: newId, label: "", state: "custom" }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCustomConfirm = (chipId: string, value: string) => {
|
const handleCustomConfirm = (chipId: string, value: string) => {
|
||||||
setOptions((prev) =>
|
setOptions((prev) =>
|
||||||
prev.map((opt) =>
|
prev.map((opt) =>
|
||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? { ...opt, label: value, state: "Selected" as const }
|
? { ...opt, label: value, state: "selected" as const }
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -72,7 +71,7 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-[var(--spacing-scale-016)]">
|
<div className="space-y-[var(--spacing-scale-016)]">
|
||||||
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
|
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
|
||||||
{size === "S" ? "Small (S)" : "Medium (M)"}
|
{size === "s" ? "Small (S)" : "Medium (M)"}
|
||||||
</h3>
|
</h3>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label="Label"
|
label="Label"
|
||||||
@@ -91,12 +90,12 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
|
|||||||
|
|
||||||
export default function ComponentsPreview() {
|
export default function ComponentsPreview() {
|
||||||
const [chipStates, setChipStates] = useState<
|
const [chipStates, setChipStates] = useState<
|
||||||
Record<string, "Unselected" | "Selected">
|
Record<string, "unselected" | "selected">
|
||||||
>({
|
>({
|
||||||
"default-s": "Unselected",
|
"default-s": "unselected",
|
||||||
"default-m": "Unselected",
|
"default-m": "unselected",
|
||||||
"inverse-s": "Unselected",
|
"inverse-s": "unselected",
|
||||||
"inverse-m": "Unselected",
|
"inverse-m": "unselected",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Manage custom chips separately
|
// Manage custom chips separately
|
||||||
@@ -104,16 +103,16 @@ export default function ComponentsPreview() {
|
|||||||
{
|
{
|
||||||
id: "custom-1",
|
id: "custom-1",
|
||||||
label: "",
|
label: "",
|
||||||
state: "Custom",
|
state: "custom",
|
||||||
palette: "Default",
|
palette: "default",
|
||||||
size: "S",
|
size: "s",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "custom-2",
|
id: "custom-2",
|
||||||
label: "",
|
label: "",
|
||||||
state: "Custom",
|
state: "custom",
|
||||||
palette: "Default",
|
palette: "default",
|
||||||
size: "M",
|
size: "m",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -124,7 +123,7 @@ export default function ComponentsPreview() {
|
|||||||
chipOptions: Array<{
|
chipOptions: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
state: "Unselected" | "Selected" | "Custom";
|
state: "unselected" | "selected" | "custom";
|
||||||
}>;
|
}>;
|
||||||
onChipClick?: (_categoryName: string, _chipId: string) => void;
|
onChipClick?: (_categoryName: string, _chipId: string) => void;
|
||||||
onAddClick?: (_categoryName: string) => void;
|
onAddClick?: (_categoryName: string) => void;
|
||||||
@@ -139,11 +138,11 @@ export default function ComponentsPreview() {
|
|||||||
{
|
{
|
||||||
name: "Values",
|
name: "Values",
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
{ id: "values-1", label: "Consciousness", state: "Unselected" },
|
{ id: "values-1", label: "Consciousness", state: "unselected" },
|
||||||
{ id: "values-2", label: "Ecology", state: "Unselected" },
|
{ id: "values-2", label: "Ecology", state: "unselected" },
|
||||||
{ id: "values-3", label: "Abundance", state: "Unselected" },
|
{ id: "values-3", label: "Abundance", state: "unselected" },
|
||||||
{ id: "values-4", label: "Art", state: "Unselected" },
|
{ id: "values-4", label: "Art", state: "unselected" },
|
||||||
{ id: "values-5", label: "Decisiveness", state: "Unselected" },
|
{ id: "values-5", label: "Decisiveness", state: "unselected" },
|
||||||
],
|
],
|
||||||
onChipClick: (categoryName: string, chipId: string) => {
|
onChipClick: (categoryName: string, chipId: string) => {
|
||||||
setRuleCardCategories((prev) =>
|
setRuleCardCategories((prev) =>
|
||||||
@@ -156,9 +155,9 @@ export default function ComponentsPreview() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -176,7 +175,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
...cat.chipOptions,
|
...cat.chipOptions,
|
||||||
{ id: newId, label: "", state: "Custom" },
|
{ id: newId, label: "", state: "custom" },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: cat,
|
: cat,
|
||||||
@@ -195,7 +194,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: cat.chipOptions.map((opt) =>
|
chipOptions: cat.chipOptions.map((opt) =>
|
||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? { ...opt, label: value, state: "Selected" }
|
? { ...opt, label: value, state: "selected" }
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -220,7 +219,7 @@ export default function ComponentsPreview() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Communication",
|
name: "Communication",
|
||||||
chipOptions: [{ id: "comm-1", label: "Signal", state: "Unselected" }],
|
chipOptions: [{ id: "comm-1", label: "Signal", state: "unselected" }],
|
||||||
onChipClick: (categoryName: string, chipId: string) => {
|
onChipClick: (categoryName: string, chipId: string) => {
|
||||||
setRuleCardCategories((prev) =>
|
setRuleCardCategories((prev) =>
|
||||||
prev.map((cat) =>
|
prev.map((cat) =>
|
||||||
@@ -232,9 +231,9 @@ export default function ComponentsPreview() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -252,7 +251,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
...cat.chipOptions,
|
...cat.chipOptions,
|
||||||
{ id: newId, label: "", state: "Custom" },
|
{ id: newId, label: "", state: "custom" },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: cat,
|
: cat,
|
||||||
@@ -271,7 +270,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: cat.chipOptions.map((opt) =>
|
chipOptions: cat.chipOptions.map((opt) =>
|
||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? { ...opt, label: value, state: "Selected" }
|
? { ...opt, label: value, state: "selected" }
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -297,7 +296,7 @@ export default function ComponentsPreview() {
|
|||||||
{
|
{
|
||||||
name: "Membership",
|
name: "Membership",
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
{ id: "membership-1", label: "Open Admission", state: "Unselected" },
|
{ id: "membership-1", label: "Open Admission", state: "unselected" },
|
||||||
],
|
],
|
||||||
onChipClick: (categoryName: string, chipId: string) => {
|
onChipClick: (categoryName: string, chipId: string) => {
|
||||||
setRuleCardCategories((prev) =>
|
setRuleCardCategories((prev) =>
|
||||||
@@ -310,9 +309,9 @@ export default function ComponentsPreview() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -330,7 +329,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
...cat.chipOptions,
|
...cat.chipOptions,
|
||||||
{ id: newId, label: "", state: "Custom" },
|
{ id: newId, label: "", state: "custom" },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: cat,
|
: cat,
|
||||||
@@ -349,7 +348,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: cat.chipOptions.map((opt) =>
|
chipOptions: cat.chipOptions.map((opt) =>
|
||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? { ...opt, label: value, state: "Selected" }
|
? { ...opt, label: value, state: "selected" }
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -375,8 +374,8 @@ export default function ComponentsPreview() {
|
|||||||
{
|
{
|
||||||
name: "Decision-making",
|
name: "Decision-making",
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
{ id: "decision-1", label: "Lazy Consensus", state: "Unselected" },
|
{ id: "decision-1", label: "Lazy Consensus", state: "unselected" },
|
||||||
{ id: "decision-2", label: "Modified Consensus", state: "Unselected" },
|
{ id: "decision-2", label: "Modified Consensus", state: "unselected" },
|
||||||
],
|
],
|
||||||
onChipClick: (categoryName: string, chipId: string) => {
|
onChipClick: (categoryName: string, chipId: string) => {
|
||||||
setRuleCardCategories((prev) =>
|
setRuleCardCategories((prev) =>
|
||||||
@@ -389,9 +388,9 @@ export default function ComponentsPreview() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -409,7 +408,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
...cat.chipOptions,
|
...cat.chipOptions,
|
||||||
{ id: newId, label: "", state: "Custom" },
|
{ id: newId, label: "", state: "custom" },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: cat,
|
: cat,
|
||||||
@@ -428,7 +427,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: cat.chipOptions.map((opt) =>
|
chipOptions: cat.chipOptions.map((opt) =>
|
||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? { ...opt, label: value, state: "Selected" }
|
? { ...opt, label: value, state: "selected" }
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -454,8 +453,8 @@ export default function ComponentsPreview() {
|
|||||||
{
|
{
|
||||||
name: "Conflict management",
|
name: "Conflict management",
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
{ id: "conflict-1", label: "Code of Conduct", state: "Unselected" },
|
{ id: "conflict-1", label: "Code of Conduct", state: "unselected" },
|
||||||
{ id: "conflict-2", label: "Restorative Justice", state: "Unselected" },
|
{ id: "conflict-2", label: "Restorative Justice", state: "unselected" },
|
||||||
],
|
],
|
||||||
onChipClick: (categoryName: string, chipId: string) => {
|
onChipClick: (categoryName: string, chipId: string) => {
|
||||||
setRuleCardCategories((prev) =>
|
setRuleCardCategories((prev) =>
|
||||||
@@ -468,9 +467,9 @@ export default function ComponentsPreview() {
|
|||||||
? {
|
? {
|
||||||
...opt,
|
...opt,
|
||||||
state:
|
state:
|
||||||
opt.state === "Selected"
|
opt.state === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}
|
}
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
@@ -488,7 +487,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: [
|
chipOptions: [
|
||||||
...cat.chipOptions,
|
...cat.chipOptions,
|
||||||
{ id: newId, label: "", state: "Custom" },
|
{ id: newId, label: "", state: "custom" },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: cat,
|
: cat,
|
||||||
@@ -507,7 +506,7 @@ export default function ComponentsPreview() {
|
|||||||
...cat,
|
...cat,
|
||||||
chipOptions: cat.chipOptions.map((opt) =>
|
chipOptions: cat.chipOptions.map((opt) =>
|
||||||
opt.id === chipId
|
opt.id === chipId
|
||||||
? { ...opt, label: value, state: "Selected" }
|
? { ...opt, label: value, state: "selected" }
|
||||||
: opt,
|
: opt,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -560,45 +559,45 @@ export default function ComponentsPreview() {
|
|||||||
<Chip
|
<Chip
|
||||||
label="Small"
|
label="Small"
|
||||||
state={chipStates["default-s"]}
|
state={chipStates["default-s"]}
|
||||||
palette="Default"
|
palette="default"
|
||||||
size="S"
|
size="s"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setChipStates((prev) => ({
|
setChipStates((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
"default-s":
|
"default-s":
|
||||||
prev["default-s"] === "Selected"
|
prev["default-s"] === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
label="Medium"
|
label="Medium"
|
||||||
state={chipStates["default-m"]}
|
state={chipStates["default-m"]}
|
||||||
palette="Default"
|
palette="default"
|
||||||
size="M"
|
size="m"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setChipStates((prev) => ({
|
setChipStates((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
"default-m":
|
"default-m":
|
||||||
prev["default-m"] === "Selected"
|
prev["default-m"] === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
label="Disabled"
|
label="Disabled"
|
||||||
state="Disabled"
|
state="disabled"
|
||||||
palette="Default"
|
palette="default"
|
||||||
size="S"
|
size="s"
|
||||||
/>
|
/>
|
||||||
{customChips
|
{customChips
|
||||||
.filter((chip) => chip.palette === "Default")
|
.filter((chip) => chip.palette === "default")
|
||||||
.map((chip) => (
|
.map((chip) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={chip.id}
|
key={chip.id}
|
||||||
label={chip.state === "Custom" ? "" : chip.label}
|
label={chip.state === "custom" ? "" : chip.label}
|
||||||
state={chip.state}
|
state={chip.state}
|
||||||
palette={chip.palette}
|
palette={chip.palette}
|
||||||
size={chip.size}
|
size={chip.size}
|
||||||
@@ -607,7 +606,7 @@ export default function ComponentsPreview() {
|
|||||||
setCustomChips((prev) =>
|
setCustomChips((prev) =>
|
||||||
prev.map((c) =>
|
prev.map((c) =>
|
||||||
c.id === chip.id
|
c.id === chip.id
|
||||||
? { ...c, label: value, state: "Selected" }
|
? { ...c, label: value, state: "selected" }
|
||||||
: c,
|
: c,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -622,8 +621,8 @@ export default function ComponentsPreview() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// Only toggle if the chip is in Selected or Unselected state (not Custom)
|
// Only toggle if the chip is in Selected or Unselected state (not Custom)
|
||||||
if (
|
if (
|
||||||
chip.state === "Selected" ||
|
chip.state === "selected" ||
|
||||||
chip.state === "Unselected"
|
chip.state === "unselected"
|
||||||
) {
|
) {
|
||||||
setCustomChips((prev) =>
|
setCustomChips((prev) =>
|
||||||
prev.map((c) =>
|
prev.map((c) =>
|
||||||
@@ -631,9 +630,9 @@ export default function ComponentsPreview() {
|
|||||||
? {
|
? {
|
||||||
...c,
|
...c,
|
||||||
state:
|
state:
|
||||||
c.state === "Selected"
|
c.state === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}
|
}
|
||||||
: c,
|
: c,
|
||||||
),
|
),
|
||||||
@@ -652,9 +651,9 @@ export default function ComponentsPreview() {
|
|||||||
{
|
{
|
||||||
id: newId,
|
id: newId,
|
||||||
label: "",
|
label: "",
|
||||||
state: "Custom",
|
state: "custom",
|
||||||
palette: "Default",
|
palette: "default",
|
||||||
size: "S",
|
size: "s",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}}
|
}}
|
||||||
@@ -698,38 +697,38 @@ export default function ComponentsPreview() {
|
|||||||
<Chip
|
<Chip
|
||||||
label="Small"
|
label="Small"
|
||||||
state={chipStates["inverse-s"]}
|
state={chipStates["inverse-s"]}
|
||||||
palette="Inverse"
|
palette="inverse"
|
||||||
size="S"
|
size="s"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setChipStates((prev) => ({
|
setChipStates((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
"inverse-s":
|
"inverse-s":
|
||||||
prev["inverse-s"] === "Selected"
|
prev["inverse-s"] === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
label="Medium"
|
label="Medium"
|
||||||
state={chipStates["inverse-m"]}
|
state={chipStates["inverse-m"]}
|
||||||
palette="Inverse"
|
palette="inverse"
|
||||||
size="M"
|
size="m"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setChipStates((prev) => ({
|
setChipStates((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
"inverse-m":
|
"inverse-m":
|
||||||
prev["inverse-m"] === "Selected"
|
prev["inverse-m"] === "selected"
|
||||||
? "Unselected"
|
? "unselected"
|
||||||
: "Selected",
|
: "selected",
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Chip
|
<Chip
|
||||||
label="Disabled"
|
label="Disabled"
|
||||||
state="Disabled"
|
state="disabled"
|
||||||
palette="Inverse"
|
palette="inverse"
|
||||||
size="S"
|
size="s"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -959,10 +958,10 @@ export default function ComponentsPreview() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
|
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
|
||||||
{/* Small size */}
|
{/* Small size */}
|
||||||
<MultiSelectExample size="S" />
|
<MultiSelectExample size="s" />
|
||||||
|
|
||||||
{/* Medium size */}
|
{/* Medium size */}
|
||||||
<MultiSelectExample size="M" />
|
<MultiSelectExample size="m" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Development-only previews (e.g. `/components-preview`) — no public chrome.
|
||||||
|
// Routes here are gated by NODE_ENV checks at the page level.
|
||||||
|
export default function DevLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <main className="flex-1">{children}</main>;
|
||||||
|
}
|
||||||
+4
-4
@@ -1,9 +1,9 @@
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { listRuleTemplatesFromDb } from "../../lib/server/ruleTemplates";
|
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
|
||||||
import { GOVERNANCE_TEMPLATE_HOME_SLUGS } from "../../lib/templates/governanceTemplateCatalog";
|
import { GOVERNANCE_TEMPLATE_HOME_SLUGS } from "../../../lib/templates/governanceTemplateCatalog";
|
||||||
import { gridEntriesForSlugOrderWithCatalogFallback } from "../../lib/templates/templateGridPresentation";
|
import { gridEntriesForSlugOrderWithCatalogFallback } from "../../../lib/templates/templateGridPresentation";
|
||||||
|
|
||||||
const RuleStack = dynamic(() => import("../components/sections/RuleStack"), {
|
const RuleStack = dynamic(() => import("../../components/sections/RuleStack"), {
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
|
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
|
||||||
),
|
),
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// Site footer is part of the public marketing chrome only — not rendered for
|
||||||
|
// signed-in product surfaces, admin dashboards, or dev previews. See
|
||||||
|
// `.cursor/rules/routes.mdc` for the full chrome composition map.
|
||||||
|
const Footer = dynamic(() => import("../components/navigation/Footer"), {
|
||||||
|
loading: () => (
|
||||||
|
<footer className="bg-[var(--color-surface-default-primary)] w-full min-h-[200px]" />
|
||||||
|
),
|
||||||
|
ssr: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function MarketingLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import messages from "../../messages/en/index";
|
|||||||
import { getTranslation } from "../../lib/i18n/getTranslation";
|
import { getTranslation } from "../../lib/i18n/getTranslation";
|
||||||
import HeroBanner from "../components/sections/HeroBanner";
|
import HeroBanner from "../components/sections/HeroBanner";
|
||||||
import AskOrganizer from "../components/sections/AskOrganizer";
|
import AskOrganizer from "../components/sections/AskOrganizer";
|
||||||
import { MarketingRuleStackSection } from "./MarketingRuleStackSection";
|
import { MarketingRuleStackSection } from "./_components/MarketingRuleStackSection";
|
||||||
|
|
||||||
// Code split below-the-fold components to reduce initial bundle size
|
// Code split below-the-fold components to reduce initial bundle size
|
||||||
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
|
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
|
||||||
|
|||||||
@@ -5,34 +5,26 @@ import type {
|
|||||||
ButtonPaletteValue,
|
ButtonPaletteValue,
|
||||||
ButtonStateValue,
|
ButtonStateValue,
|
||||||
} from "../../../lib/propNormalization";
|
} from "../../../lib/propNormalization";
|
||||||
import {
|
|
||||||
normalizeSize,
|
|
||||||
normalizeButtonType,
|
|
||||||
normalizeButtonPalette,
|
|
||||||
} from "../../../lib/propNormalization";
|
|
||||||
|
|
||||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
/**
|
/**
|
||||||
* Button type (Figma prop). Accepts both lowercase and PascalCase (case-insensitive).
|
* Button type (Figma prop).
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
* @default "filled"
|
* @default "filled"
|
||||||
*/
|
*/
|
||||||
buttonType?: ButtonTypeValue;
|
buttonType?: ButtonTypeValue;
|
||||||
/**
|
/**
|
||||||
* Button palette (Figma prop). Accepts both lowercase and PascalCase (case-insensitive).
|
* Button palette (Figma prop).
|
||||||
* Figma uses "Invert", codebase uses "inverse" - both are supported.
|
|
||||||
* @default "default"
|
* @default "default"
|
||||||
*/
|
*/
|
||||||
palette?: ButtonPaletteValue;
|
palette?: ButtonPaletteValue;
|
||||||
/**
|
/**
|
||||||
* Button size. Accepts both lowercase and PascalCase (case-insensitive).
|
* Button size.
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
* @default "xsmall"
|
* @default "xsmall"
|
||||||
*/
|
*/
|
||||||
size?: SizeValue;
|
size?: SizeValue;
|
||||||
/**
|
/**
|
||||||
* Button state (Figma prop). Accepts both lowercase and PascalCase (case-insensitive).
|
* Button state (Figma prop).
|
||||||
* @default "default"
|
* @default "default"
|
||||||
*/
|
*/
|
||||||
state?: ButtonStateValue;
|
state?: ButtonStateValue;
|
||||||
@@ -83,12 +75,9 @@ const Button = memo<ButtonProps>(
|
|||||||
ariaLabel,
|
ariaLabel,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// Normalize props
|
const buttonType = typeProp ?? "filled";
|
||||||
const buttonType = normalizeButtonType(typeProp, "filled");
|
const buttonPalette = paletteProp ?? "default";
|
||||||
const buttonPalette = normalizeButtonPalette(paletteProp, "default");
|
const size = sizeProp;
|
||||||
const size = normalizeSize(sizeProp);
|
|
||||||
// State prop is for Figma alignment - actual state is handled by CSS pseudo-classes
|
|
||||||
// We accept it for API alignment but don't use it for styling (CSS handles states)
|
|
||||||
|
|
||||||
// Map type + palette to variant string for styling (internal use only)
|
// Map type + palette to variant string for styling (internal use only)
|
||||||
const getVariantFromTypeAndPalette = (
|
const getVariantFromTypeAndPalette = (
|
||||||
|
|||||||
@@ -3,80 +3,57 @@
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import SectionNumber from "../sections/SectionNumber";
|
import SectionNumber from "../sections/SectionNumber";
|
||||||
|
|
||||||
import { normalizeNumberCardSize } from "../../../lib/propNormalization";
|
export type NumberCardSizeValue = "small" | "medium" | "large" | "xlarge";
|
||||||
|
|
||||||
export type NumberCardSizeValue =
|
|
||||||
| "Small"
|
|
||||||
| "Medium"
|
|
||||||
| "Large"
|
|
||||||
| "XLarge"
|
|
||||||
| "small"
|
|
||||||
| "medium"
|
|
||||||
| "large"
|
|
||||||
| "xlarge";
|
|
||||||
|
|
||||||
interface NumberCardProps {
|
interface NumberCardProps {
|
||||||
number: number;
|
number: number;
|
||||||
text: string;
|
text: string;
|
||||||
/**
|
|
||||||
* Number card size. Accepts both PascalCase (Figma default) and lowercase (case-insensitive).
|
|
||||||
* Figma uses PascalCase, codebase uses PascalCase - both are supported.
|
|
||||||
*/
|
|
||||||
size?: NumberCardSizeValue;
|
size?: NumberCardSizeValue;
|
||||||
iconShape?: string;
|
iconShape?: string;
|
||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NumberCard = memo<NumberCardProps>(({ number, text, size: sizeProp }) => {
|
const NumberCard = memo<NumberCardProps>(({ number, text, size: sizeProp }) => {
|
||||||
// Base classes common to all sizes
|
|
||||||
const baseClasses =
|
const baseClasses =
|
||||||
"bg-[var(--color-surface-inverse-primary)] rounded-[12px] shadow-lg";
|
"bg-[var(--color-surface-inverse-primary)] rounded-[12px] shadow-lg";
|
||||||
|
|
||||||
// If size prop is provided, use explicit size classes
|
|
||||||
// Otherwise, use responsive breakpoints for backward compatibility
|
|
||||||
if (sizeProp) {
|
if (sizeProp) {
|
||||||
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
|
const size = sizeProp;
|
||||||
const size = normalizeNumberCardSize(sizeProp);
|
|
||||||
// Size-specific classes
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
Small: "flex flex-col items-end justify-center gap-4 p-5 relative",
|
small: "flex flex-col items-end justify-center gap-4 p-5 relative",
|
||||||
Medium: "flex flex-row items-center gap-8 p-8 relative",
|
medium: "flex flex-row items-center gap-8 p-8 relative",
|
||||||
Large:
|
large:
|
||||||
"flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative",
|
"flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative",
|
||||||
XLarge:
|
xlarge:
|
||||||
"flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative",
|
"flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Text size classes
|
|
||||||
const textClasses = {
|
const textClasses = {
|
||||||
Small:
|
small:
|
||||||
"font-bricolage-grotesque font-medium text-[24px] leading-[32px] text-[#141414]",
|
"font-bricolage-grotesque font-medium text-[24px] leading-[32px] text-[#141414]",
|
||||||
Medium:
|
medium:
|
||||||
"font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
|
"font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
|
||||||
Large:
|
large:
|
||||||
"font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
|
"font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
|
||||||
XLarge:
|
xlarge:
|
||||||
"font-bricolage-grotesque font-medium text-[32px] leading-[32px] text-[#141414]",
|
"font-bricolage-grotesque font-medium text-[32px] leading-[32px] text-[#141414]",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Section number wrapper classes - Small doesn't need a wrapper
|
|
||||||
const sectionNumberWrapperClasses = {
|
const sectionNumberWrapperClasses = {
|
||||||
Small: "relative shrink-0",
|
small: "relative shrink-0",
|
||||||
Medium: "flex justify-start flex-shrink-0",
|
medium: "flex justify-start flex-shrink-0",
|
||||||
Large: "absolute top-8 right-8",
|
large: "absolute top-8 right-8",
|
||||||
XLarge: "absolute top-8 right-8",
|
xlarge: "absolute top-8 right-8",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Content container classes
|
|
||||||
const contentClasses = {
|
const contentClasses = {
|
||||||
Small: "min-w-full relative shrink-0",
|
small: "min-w-full relative shrink-0",
|
||||||
Medium: "flex-1",
|
medium: "flex-1",
|
||||||
Large: "absolute bottom-8 left-8 right-16",
|
large: "absolute bottom-8 left-8 right-16",
|
||||||
XLarge: "absolute bottom-8 left-8 right-16",
|
xlarge: "absolute bottom-8 left-8 right-16",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Small variant has section number as direct child, others need wrapper
|
if (size === "small") {
|
||||||
if (size === "Small") {
|
|
||||||
return (
|
return (
|
||||||
<div className={`${baseClasses} ${sizeClasses[size]}`}>
|
<div className={`${baseClasses} ${sizeClasses[size]}`}>
|
||||||
{/* Section Number - Direct child for Small */}
|
{/* Section Number - Direct child for Small */}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { RuleCardView } from "./RuleCard.view";
|
import { RuleCardView } from "./RuleCard.view";
|
||||||
import type { RuleCardProps } from "./RuleCard.types";
|
import type { RuleCardProps } from "./RuleCard.types";
|
||||||
import { normalizeRuleCardSize } from "../../../../lib/propNormalization";
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -33,8 +32,7 @@ const RuleCardContainer = memo<RuleCardProps>(
|
|||||||
logoAlt,
|
logoAlt,
|
||||||
communityInitials,
|
communityInitials,
|
||||||
}) => {
|
}) => {
|
||||||
// Normalize size prop
|
const size = sizeProp ?? "L";
|
||||||
const size = normalizeRuleCardSize(sizeProp, "L");
|
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
// Basic analytics event tracking
|
// Basic analytics event tracking
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface RuleCardProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
size?: "XS" | "S" | "M" | "L" | "xs" | "s" | "m" | "l";
|
size?: "XS" | "S" | "M" | "L";
|
||||||
categories?: Category[];
|
categories?: Category[];
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
logoAlt?: string;
|
logoAlt?: string;
|
||||||
|
|||||||
@@ -261,8 +261,8 @@ export function RuleCardView({
|
|||||||
key={categoryIndex}
|
key={categoryIndex}
|
||||||
label={category.name}
|
label={category.name}
|
||||||
showHelpIcon={false}
|
showHelpIcon={false}
|
||||||
size="S"
|
size="s"
|
||||||
palette="Inverse"
|
palette="inverse"
|
||||||
options={category.chipOptions}
|
options={category.chipOptions}
|
||||||
onChipClick={(chipId) => {
|
onChipClick={(chipId) => {
|
||||||
category.onChipClick?.(category.name, chipId);
|
category.onChipClick?.(category.name, chipId);
|
||||||
|
|||||||
@@ -4,12 +4,10 @@ import { memo } from "react";
|
|||||||
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
||||||
import ContentContainerView from "./ContentContainer.view";
|
import ContentContainerView from "./ContentContainer.view";
|
||||||
import type { ContentContainerProps } from "./ContentContainer.types";
|
import type { ContentContainerProps } from "./ContentContainer.types";
|
||||||
import { normalizeContentContainerSize } from "../../../../lib/propNormalization";
|
|
||||||
|
|
||||||
const ContentContainerContainer = memo<ContentContainerProps>(
|
const ContentContainerContainer = memo<ContentContainerProps>(
|
||||||
({ post, width = "200px", size: sizeProp = "responsive" }) => {
|
({ post, width = "200px", size: sizeProp = "responsive" }) => {
|
||||||
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
|
const size = sizeProp;
|
||||||
const size = normalizeContentContainerSize(sizeProp);
|
|
||||||
// Get the corresponding icon based on the same logic as background images
|
// Get the corresponding icon based on the same logic as background images
|
||||||
const getIconImage = (slug: string): string => {
|
const getIconImage = (slug: string): string => {
|
||||||
const icons = [
|
const icons = [
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import type { BlogPost } from "../../../../lib/content";
|
import type { BlogPost } from "../../../../lib/content";
|
||||||
|
|
||||||
export type ContentContainerSizeValue =
|
export type ContentContainerSizeValue = "xs" | "responsive";
|
||||||
| "xs"
|
|
||||||
| "responsive"
|
|
||||||
| "Xs"
|
|
||||||
| "Responsive";
|
|
||||||
|
|
||||||
export interface ContentContainerProps {
|
export interface ContentContainerProps {
|
||||||
post: BlogPost;
|
post: BlogPost;
|
||||||
width?: string;
|
width?: string;
|
||||||
/**
|
/**
|
||||||
* Content container size. Accepts both lowercase and PascalCase (case-insensitive).
|
* Content container size.
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
*/
|
*/
|
||||||
size?: ContentContainerSizeValue;
|
size?: ContentContainerSizeValue;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -4,12 +4,10 @@ import { memo } from "react";
|
|||||||
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
||||||
import ContentThumbnailTemplateView from "./ContentThumbnailTemplate.view";
|
import ContentThumbnailTemplateView from "./ContentThumbnailTemplate.view";
|
||||||
import type { ContentThumbnailTemplateProps } from "./ContentThumbnailTemplate.types";
|
import type { ContentThumbnailTemplateProps } from "./ContentThumbnailTemplate.types";
|
||||||
import { normalizeContentThumbnailVariant } from "../../../../lib/propNormalization";
|
|
||||||
|
|
||||||
const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
||||||
({ post, className = "", variant: variantProp = "vertical" }) => {
|
({ post, className = "", variant: variantProp = "vertical" }) => {
|
||||||
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
|
const variant = variantProp;
|
||||||
const variant = normalizeContentThumbnailVariant(variantProp);
|
|
||||||
// Get article-specific background image from frontmatter
|
// Get article-specific background image from frontmatter
|
||||||
const getBackgroundImage = (
|
const getBackgroundImage = (
|
||||||
post: ContentThumbnailTemplateProps["post"],
|
post: ContentThumbnailTemplateProps["post"],
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import type { BlogPost } from "../../../../lib/content";
|
import type { BlogPost } from "../../../../lib/content";
|
||||||
|
|
||||||
export type ContentThumbnailTemplateVariantValue =
|
export type ContentThumbnailTemplateVariantValue = "vertical" | "horizontal";
|
||||||
| "vertical"
|
|
||||||
| "horizontal"
|
|
||||||
| "Vertical"
|
|
||||||
| "Horizontal";
|
|
||||||
|
|
||||||
export interface ContentThumbnailTemplateProps {
|
export interface ContentThumbnailTemplateProps {
|
||||||
post: BlogPost;
|
post: BlogPost;
|
||||||
className?: string;
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* Content thumbnail variant. Accepts both lowercase and PascalCase (case-insensitive).
|
* Content thumbnail variant.
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
*/
|
*/
|
||||||
variant?: ContentThumbnailTemplateVariantValue;
|
variant?: ContentThumbnailTemplateVariantValue;
|
||||||
slugOrder?: string[];
|
slugOrder?: string[];
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { memo } from "react";
|
|||||||
import { useComponentId } from "../../../hooks";
|
import { useComponentId } from "../../../hooks";
|
||||||
import { CheckboxView } from "./Checkbox.view";
|
import { CheckboxView } from "./Checkbox.view";
|
||||||
import type { CheckboxProps } from "./Checkbox.types";
|
import type { CheckboxProps } from "./Checkbox.types";
|
||||||
import {
|
|
||||||
normalizeMode,
|
|
||||||
normalizeState,
|
|
||||||
} from "../../../../lib/propNormalization";
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma: "Control / Checkbox" (TODO(figma)). Single boolean checkbox with
|
||||||
|
* optional label, supporting standard and inverse modes.
|
||||||
|
*/
|
||||||
const CheckboxContainer = memo<CheckboxProps>(
|
const CheckboxContainer = memo<CheckboxProps>(
|
||||||
({
|
({
|
||||||
checked = false,
|
checked = false,
|
||||||
@@ -24,9 +24,8 @@ const CheckboxContainer = memo<CheckboxProps>(
|
|||||||
ariaLabel,
|
ariaLabel,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
|
const mode = modeProp;
|
||||||
const mode = normalizeMode(modeProp);
|
const state = stateProp;
|
||||||
const state = normalizeState(stateProp);
|
|
||||||
|
|
||||||
const isInverse = mode === "inverse";
|
const isInverse = mode === "inverse";
|
||||||
const isStandard = mode === "standard";
|
const isStandard = mode === "standard";
|
||||||
|
|||||||
@@ -2,15 +2,9 @@ import type { ModeValue, StateValue } from "../../../../lib/propNormalization";
|
|||||||
|
|
||||||
export interface CheckboxProps {
|
export interface CheckboxProps {
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
/**
|
/** Mode variant (Figma: Mode). */
|
||||||
* Mode variant. Accepts both "standard"/"Standard" and "inverse"/"Inverse" (case-insensitive).
|
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
*/
|
|
||||||
mode?: ModeValue;
|
mode?: ModeValue;
|
||||||
/**
|
/** Visual state (Figma: State). */
|
||||||
* Visual state. Accepts "default"/"Default", "hover"/"Hover", "focus"/"Focus" (case-insensitive).
|
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
*/
|
|
||||||
state?: StateValue;
|
state?: StateValue;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
import { memo, useCallback, useId, useState } from "react";
|
import { memo, useCallback, useId, useState } from "react";
|
||||||
import { CheckboxGroupView } from "./CheckboxGroup.view";
|
import { CheckboxGroupView } from "./CheckboxGroup.view";
|
||||||
import type { CheckboxGroupProps } from "./CheckboxGroup.types";
|
import type { CheckboxGroupProps } from "./CheckboxGroup.types";
|
||||||
import { normalizeMode } from "../../../../lib/propNormalization";
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma: "Control / CheckboxGroup" (TODO(figma)). Group of checkboxes sharing
|
||||||
|
* a name that emits the array of currently selected values.
|
||||||
|
*/
|
||||||
const CheckboxGroupContainer = ({
|
const CheckboxGroupContainer = ({
|
||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
@@ -15,8 +18,7 @@ const CheckboxGroupContainer = ({
|
|||||||
className = "",
|
className = "",
|
||||||
...props
|
...props
|
||||||
}: CheckboxGroupProps) => {
|
}: CheckboxGroupProps) => {
|
||||||
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
|
const mode = modeProp;
|
||||||
const mode = normalizeMode(modeProp);
|
|
||||||
// Generate unique ID for accessibility if not provided
|
// Generate unique ID for accessibility if not provided
|
||||||
const generatedId = useId();
|
const generatedId = useId();
|
||||||
const groupId = name || `checkbox-group-${generatedId}`;
|
const groupId = name || `checkbox-group-${generatedId}`;
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ export interface CheckboxGroupProps {
|
|||||||
value?: string[];
|
value?: string[];
|
||||||
onChange?: (_data: { value: string[] }) => void;
|
onChange?: (_data: { value: string[] }) => void;
|
||||||
/**
|
/**
|
||||||
* Mode variant. Accepts both "standard"/"Standard" and "inverse"/"Inverse" (case-insensitive).
|
* Mode variant.
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
*/
|
*/
|
||||||
mode?: ModeValue;
|
mode?: ModeValue;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|||||||
@@ -3,18 +3,17 @@
|
|||||||
import { memo, useState, useEffect, useRef } from "react";
|
import { memo, useState, useEffect, useRef } from "react";
|
||||||
import ChipView from "./Chip.view";
|
import ChipView from "./Chip.view";
|
||||||
import type { ChipProps } from "./Chip.types";
|
import type { ChipProps } from "./Chip.types";
|
||||||
import {
|
|
||||||
normalizeChipPalette,
|
|
||||||
normalizeChipSize,
|
|
||||||
normalizeChipState,
|
|
||||||
} from "../../../../lib/propNormalization";
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma: "Control / Chip" (TODO(figma)). Compact pill-shaped tag with
|
||||||
|
* selectable, removable, and inline-editable (custom) states.
|
||||||
|
*/
|
||||||
const ChipContainer = memo<ChipProps>(
|
const ChipContainer = memo<ChipProps>(
|
||||||
({
|
({
|
||||||
label,
|
label,
|
||||||
state: stateProp = "Unselected",
|
state: stateProp = "unselected",
|
||||||
palette: paletteProp = "Default",
|
palette: paletteProp = "default",
|
||||||
size: sizeProp = "S",
|
size: sizeProp = "s",
|
||||||
className = "",
|
className = "",
|
||||||
disabled,
|
disabled,
|
||||||
onClick,
|
onClick,
|
||||||
@@ -23,9 +22,9 @@ const ChipContainer = memo<ChipProps>(
|
|||||||
onClose,
|
onClose,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
}) => {
|
}) => {
|
||||||
const state = normalizeChipState(stateProp);
|
const state = stateProp;
|
||||||
const palette = normalizeChipPalette(paletteProp);
|
const palette = paletteProp;
|
||||||
const size = normalizeChipSize(sizeProp);
|
const size = sizeProp;
|
||||||
|
|
||||||
const isDisabled = disabled ?? state === "disabled";
|
const isDisabled = disabled ?? state === "disabled";
|
||||||
const isCustom = state === "custom";
|
const isCustom = state === "custom";
|
||||||
|
|||||||
@@ -7,38 +7,32 @@ import type {
|
|||||||
export interface ChipProps {
|
export interface ChipProps {
|
||||||
label: string;
|
label: string;
|
||||||
/**
|
/**
|
||||||
* Visual state of the chip, aligned with Figma:
|
* Visual state of the chip:
|
||||||
* - "Unselected"
|
* - "unselected"
|
||||||
* - "Selected"
|
* - "selected"
|
||||||
* - "Disabled"
|
* - "disabled"
|
||||||
* - "Custom" (editable chips with check/close buttons)
|
* - "custom" (editable chips with check/close buttons)
|
||||||
*
|
|
||||||
* Accepts both PascalCase (Figma) and lowercase values.
|
|
||||||
*/
|
*/
|
||||||
state?: ChipStateValue;
|
state?: ChipStateValue;
|
||||||
/**
|
/**
|
||||||
* Palette of the chip, aligned with Figma:
|
* Palette of the chip:
|
||||||
* - "Default"
|
* - "default"
|
||||||
* - "Inverse"
|
* - "inverse"
|
||||||
*
|
|
||||||
* Accepts both PascalCase (Figma) and lowercase values.
|
|
||||||
*/
|
*/
|
||||||
palette?: ChipPaletteValue;
|
palette?: ChipPaletteValue;
|
||||||
/**
|
/**
|
||||||
* Size of the chip, aligned with Figma:
|
* Size of the chip:
|
||||||
* - "S"
|
* - "s"
|
||||||
* - "M"
|
* - "m"
|
||||||
*
|
|
||||||
* Accepts both uppercase (Figma) and lowercase values.
|
|
||||||
*/
|
*/
|
||||||
size?: ChipSizeValue;
|
size?: ChipSizeValue;
|
||||||
className?: string;
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* Whether the chip should be non-interactive. Defaults to `true` when
|
* Whether the chip should be non-interactive. Defaults to `true` when
|
||||||
* `state === "disabled"` to preserve historical behavior. Pass
|
* `state === "disabled"` to preserve historical behavior. Pass
|
||||||
* `disabled={false}` alongside `state="Disabled"` to render the dimmed
|
* `disabled={false}` alongside `state="disabled"` to render the dimmed
|
||||||
* "disabled" visual while keeping the chip clickable — useful for toggle
|
* "disabled" visual while keeping the chip clickable — useful for toggle
|
||||||
* groups where the unselected state is the disabled Figma visual.
|
* groups where the unselected state is the disabled visual.
|
||||||
*/
|
*/
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { InputWithCounterView } from "./InputWithCounter.view";
|
||||||
|
import type { InputWithCounterProps } from "./InputWithCounter.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma: "Control / InputWithCounter" (TODO(figma)).
|
||||||
|
* Single-line text input with a label, optional help glyph, and a live
|
||||||
|
* `value.length / maxLength` counter underneath.
|
||||||
|
*/
|
||||||
|
const InputWithCounterContainer = memo<InputWithCounterProps>((props) => {
|
||||||
|
return <InputWithCounterView {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
InputWithCounterContainer.displayName = "InputWithCounter";
|
||||||
|
|
||||||
|
export default InputWithCounterContainer;
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export { InputWithCounterView as default } from "./InputWithCounter.view";
|
export { default } from "./InputWithCounter.container";
|
||||||
export type { InputWithCounterProps } from "./InputWithCounter.types";
|
export type { InputWithCounterProps } from "./InputWithCounter.types";
|
||||||
|
|||||||
@@ -3,17 +3,17 @@
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import MultiSelectView from "./MultiSelect.view";
|
import MultiSelectView from "./MultiSelect.view";
|
||||||
import type { MultiSelectProps } from "./MultiSelect.types";
|
import type { MultiSelectProps } from "./MultiSelect.types";
|
||||||
import {
|
|
||||||
normalizeMultiSelectSize,
|
|
||||||
normalizeChipPalette,
|
|
||||||
} from "../../../../lib/propNormalization";
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma: "Control / MultiSelect" (TODO(figma)). Labelled set of chips for
|
||||||
|
* picking multiple values, with an optional add button for custom entries.
|
||||||
|
*/
|
||||||
const MultiSelectContainer = memo<MultiSelectProps>(
|
const MultiSelectContainer = memo<MultiSelectProps>(
|
||||||
({
|
({
|
||||||
label,
|
label,
|
||||||
showHelpIcon = true,
|
showHelpIcon = true,
|
||||||
size: sizeProp = "M",
|
size: sizeProp = "m",
|
||||||
palette: paletteProp = "Default",
|
palette: paletteProp = "default",
|
||||||
options,
|
options,
|
||||||
onChipClick,
|
onChipClick,
|
||||||
onAddClick,
|
onAddClick,
|
||||||
@@ -24,8 +24,8 @@ const MultiSelectContainer = memo<MultiSelectProps>(
|
|||||||
onCustomChipClose,
|
onCustomChipClose,
|
||||||
className = "",
|
className = "",
|
||||||
}) => {
|
}) => {
|
||||||
const size = normalizeMultiSelectSize(sizeProp);
|
const size = sizeProp;
|
||||||
const palette = normalizeChipPalette(paletteProp);
|
const palette = paletteProp;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultiSelectView
|
<MultiSelectView
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface ChipOption {
|
|||||||
state?: ChipStateValue;
|
state?: ChipStateValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MultiSelectSizeValue = "S" | "M" | "s" | "m";
|
export type MultiSelectSizeValue = "s" | "m";
|
||||||
|
|
||||||
export interface MultiSelectProps {
|
export interface MultiSelectProps {
|
||||||
/**
|
/**
|
||||||
@@ -21,13 +21,11 @@ export interface MultiSelectProps {
|
|||||||
*/
|
*/
|
||||||
showHelpIcon?: boolean;
|
showHelpIcon?: boolean;
|
||||||
/**
|
/**
|
||||||
* Size variant: "S" (small) or "M" (medium)
|
* Size variant: "s" (small) or "m" (medium)
|
||||||
* Accepts both uppercase (Figma) and lowercase values.
|
|
||||||
*/
|
*/
|
||||||
size?: MultiSelectSizeValue;
|
size?: MultiSelectSizeValue;
|
||||||
/**
|
/**
|
||||||
* Palette for chips: "Default" or "Inverse"
|
* Palette for chips: "default" or "inverse"
|
||||||
* Accepts both PascalCase (Figma) and lowercase values.
|
|
||||||
*/
|
*/
|
||||||
palette?: ChipPaletteValue;
|
palette?: ChipPaletteValue;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function MultiSelectView({
|
|||||||
? "gap-[var(--measures-spacing-200,8px)]"
|
? "gap-[var(--measures-spacing-200,8px)]"
|
||||||
: "gap-[var(--measures-spacing-300,12px)]";
|
: "gap-[var(--measures-spacing-300,12px)]";
|
||||||
|
|
||||||
const chipSize = isSmall ? "S" : "M";
|
const chipSize = size;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -41,8 +41,8 @@ function MultiSelectView({
|
|||||||
helpIcon={showHelpIcon}
|
helpIcon={showHelpIcon}
|
||||||
asterisk={false}
|
asterisk={false}
|
||||||
helperText={false}
|
helperText={false}
|
||||||
size={size === "s" ? "S" : "M"}
|
size={size}
|
||||||
palette={palette === "inverse" ? "Inverse" : "Default"}
|
palette={palette}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -53,13 +53,12 @@ function MultiSelectView({
|
|||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<Chip
|
<Chip
|
||||||
key={option.id}
|
key={option.id}
|
||||||
label={option.state === "Custom" ? "" : option.label}
|
label={option.state === "custom" ? "" : option.label}
|
||||||
state={option.state || "Unselected"}
|
state={option.state || "unselected"}
|
||||||
palette={palette === "inverse" ? "Inverse" : "Default"}
|
palette={palette}
|
||||||
size={chipSize}
|
size={chipSize}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Only toggle if not in Custom state
|
if (option.state !== "custom" && onChipClick) {
|
||||||
if (option.state !== "Custom" && onChipClick) {
|
|
||||||
onChipClick(option.id);
|
onChipClick(option.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -3,10 +3,6 @@
|
|||||||
import { memo, useCallback, useId } from "react";
|
import { memo, useCallback, useId } from "react";
|
||||||
import { RadioButtonView } from "./RadioButton.view";
|
import { RadioButtonView } from "./RadioButton.view";
|
||||||
import type { RadioButtonProps } from "./RadioButton.types";
|
import type { RadioButtonProps } from "./RadioButton.types";
|
||||||
import {
|
|
||||||
normalizeMode,
|
|
||||||
normalizeState,
|
|
||||||
} from "../../../../lib/propNormalization";
|
|
||||||
|
|
||||||
const RadioButtonContainer = ({
|
const RadioButtonContainer = ({
|
||||||
checked = false,
|
checked = false,
|
||||||
@@ -22,9 +18,8 @@ const RadioButtonContainer = ({
|
|||||||
ariaLabel,
|
ariaLabel,
|
||||||
className = "",
|
className = "",
|
||||||
}: RadioButtonProps) => {
|
}: RadioButtonProps) => {
|
||||||
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
|
const mode = modeProp;
|
||||||
const mode = normalizeMode(modeProp);
|
const state = stateProp;
|
||||||
const state = normalizeState(stateProp);
|
|
||||||
|
|
||||||
// If state is "selected", it means checked in Figma terms
|
// If state is "selected", it means checked in Figma terms
|
||||||
const normalizedState = state === "selected" || checked ? "selected" : state;
|
const normalizedState = state === "selected" || checked ? "selected" : state;
|
||||||
|
|||||||
@@ -3,14 +3,12 @@ import type { ModeValue, StateValue } from "../../../../lib/propNormalization";
|
|||||||
export interface RadioButtonProps {
|
export interface RadioButtonProps {
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
/**
|
/**
|
||||||
* Mode variant. Accepts both "standard"/"Standard" and "inverse"/"Inverse" (case-insensitive).
|
* Mode variant.
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
*/
|
*/
|
||||||
mode?: ModeValue;
|
mode?: ModeValue;
|
||||||
/**
|
/**
|
||||||
* Visual state. Accepts "default"/"Default", "hover"/"Hover", "focus"/"Focus", "selected"/"Selected" (case-insensitive).
|
* Visual state.
|
||||||
* Note: "selected" state is represented by the `checked` prop in practice.
|
* Note: "selected" state is represented by the `checked` prop in practice.
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
*/
|
*/
|
||||||
state?: StateValue;
|
state?: StateValue;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import { memo, useCallback, useId } from "react";
|
import { memo, useCallback, useId } from "react";
|
||||||
import { RadioGroupView } from "./RadioGroup.view";
|
import { RadioGroupView } from "./RadioGroup.view";
|
||||||
import type { RadioGroupProps } from "./RadioGroup.types";
|
import type { RadioGroupProps } from "./RadioGroup.types";
|
||||||
import {
|
|
||||||
normalizeMode,
|
|
||||||
normalizeState,
|
|
||||||
} from "../../../../lib/propNormalization";
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma: "Control / RadioGroup" (TODO(figma)). Group of radio buttons sharing
|
||||||
|
* a name that emits the single currently selected value.
|
||||||
|
*/
|
||||||
const RadioGroupContainer = ({
|
const RadioGroupContainer = ({
|
||||||
name,
|
name,
|
||||||
value,
|
value,
|
||||||
@@ -19,14 +19,11 @@ const RadioGroupContainer = ({
|
|||||||
className = "",
|
className = "",
|
||||||
...props
|
...props
|
||||||
}: RadioGroupProps) => {
|
}: RadioGroupProps) => {
|
||||||
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
|
const mode = modeProp;
|
||||||
const mode = normalizeMode(modeProp);
|
const state: "default" | "hover" | "focus" | "selected" =
|
||||||
// Normalize state, but handle "With Subtext" separately (it's represented by options with subtext)
|
stateProp === "With Subtext" || stateProp === "with subtext"
|
||||||
const state =
|
? "default"
|
||||||
typeof stateProp === "string" &&
|
: stateProp;
|
||||||
(stateProp.toLowerCase() === "with subtext" || stateProp === "With Subtext")
|
|
||||||
? "default" // "With Subtext" is handled via RadioOption.subtext, use default state
|
|
||||||
: normalizeState(stateProp);
|
|
||||||
// Generate unique ID for accessibility if not provided
|
// Generate unique ID for accessibility if not provided
|
||||||
const generatedId = useId();
|
const generatedId = useId();
|
||||||
const groupId = name || `radio-group-${generatedId}`;
|
const groupId = name || `radio-group-${generatedId}`;
|
||||||
|
|||||||
@@ -12,14 +12,12 @@ export interface RadioGroupProps {
|
|||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (_data: { value: string }) => void;
|
onChange?: (_data: { value: string }) => void;
|
||||||
/**
|
/**
|
||||||
* Mode variant. Accepts both "standard"/"Standard" and "inverse"/"Inverse" (case-insensitive).
|
* Mode variant.
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
*/
|
*/
|
||||||
mode?: ModeValue;
|
mode?: ModeValue;
|
||||||
/**
|
/**
|
||||||
* Visual state. Accepts "default"/"Default", "hover"/"Hover", "focus"/"Focus" (case-insensitive).
|
* Visual state.
|
||||||
* Figma also supports "With Subtext" state, which is handled via RadioOption.subtext.
|
* Figma also supports "With Subtext" state, which is handled via RadioOption.subtext.
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
*/
|
*/
|
||||||
state?: StateValue | "With Subtext" | "with subtext";
|
state?: StateValue | "With Subtext" | "with subtext";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|||||||
@@ -16,12 +16,11 @@ import React, {
|
|||||||
import { useClickOutside } from "../../../hooks";
|
import { useClickOutside } from "../../../hooks";
|
||||||
import { SelectInputView } from "./SelectInput.view";
|
import { SelectInputView } from "./SelectInput.view";
|
||||||
import type { SelectInputProps } from "./SelectInput.types";
|
import type { SelectInputProps } from "./SelectInput.types";
|
||||||
import {
|
|
||||||
normalizeState,
|
|
||||||
normalizeSmallMediumLargeSize,
|
|
||||||
normalizeLabelVariant,
|
|
||||||
} from "../../../../lib/propNormalization";
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma: "Control / SelectInput" (TODO(figma)). Custom-styled select dropdown
|
||||||
|
* with a labelled trigger button and floating option menu.
|
||||||
|
*/
|
||||||
const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
@@ -53,22 +52,14 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
|
|||||||
const shouldShowLabel =
|
const shouldShowLabel =
|
||||||
showLabel !== undefined ? showLabel : labelText !== undefined;
|
showLabel !== undefined ? showLabel : labelText !== undefined;
|
||||||
|
|
||||||
// Normalize state - handle "state5" as disabled
|
|
||||||
let normalizedState = externalStateProp;
|
let normalizedState = externalStateProp;
|
||||||
if (normalizedState === "state5" || normalizedState === "State5") {
|
if (normalizedState === "state5" || normalizedState === "State5") {
|
||||||
normalizedState = "default"; // Map to default, disabled prop handles the disabled state
|
normalizedState = "default";
|
||||||
}
|
}
|
||||||
const externalState = normalizeState(normalizedState);
|
const externalState = normalizedState;
|
||||||
|
|
||||||
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
|
const _labelVariant = labelVariantProp;
|
||||||
// Note: labelVariant and size are normalized for future use but not yet implemented in the view
|
const _size = sizeProp;
|
||||||
const _labelVariant = labelVariantProp
|
|
||||||
? normalizeLabelVariant(labelVariantProp)
|
|
||||||
: undefined;
|
|
||||||
const _size = sizeProp
|
|
||||||
? normalizeSmallMediumLargeSize(sizeProp)
|
|
||||||
: undefined;
|
|
||||||
// Mark as intentionally unused for future implementation
|
|
||||||
void _labelVariant;
|
void _labelVariant;
|
||||||
void _size;
|
void _size;
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,8 @@ export interface SelectOptionData {
|
|||||||
|
|
||||||
import type { StateValue } from "../../../../lib/propNormalization";
|
import type { StateValue } from "../../../../lib/propNormalization";
|
||||||
|
|
||||||
export type SelectInputLabelVariantValue =
|
export type SelectInputLabelVariantValue = "default" | "horizontal";
|
||||||
| "default"
|
export type SelectInputSizeValue = "small" | "medium" | "large";
|
||||||
| "horizontal"
|
|
||||||
| "Default"
|
|
||||||
| "Horizontal";
|
|
||||||
export type SelectInputSizeValue =
|
|
||||||
| "small"
|
|
||||||
| "medium"
|
|
||||||
| "large"
|
|
||||||
| "Small"
|
|
||||||
| "Medium"
|
|
||||||
| "Large";
|
|
||||||
|
|
||||||
export interface SelectInputProps {
|
export interface SelectInputProps {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -33,18 +23,15 @@ export interface SelectInputProps {
|
|||||||
*/
|
*/
|
||||||
showLabel?: boolean;
|
showLabel?: boolean;
|
||||||
/**
|
/**
|
||||||
* Label variant. Accepts both lowercase and PascalCase (case-insensitive).
|
* Label variant.
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
*/
|
*/
|
||||||
labelVariant?: SelectInputLabelVariantValue;
|
labelVariant?: SelectInputLabelVariantValue;
|
||||||
/**
|
/**
|
||||||
* Select input size. Accepts both lowercase and PascalCase (case-insensitive).
|
* Select input size.
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
*/
|
*/
|
||||||
size?: SelectInputSizeValue;
|
size?: SelectInputSizeValue;
|
||||||
/**
|
/**
|
||||||
* Visual state. Accepts "default"/"Default", "active"/"Active", "focus"/"Focus", "error"/"Error", "state5"/"State5" (State5 = Disabled).
|
* Visual state. "state5" maps to disabled.
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
*/
|
*/
|
||||||
state?: StateValue | "state5" | "State5";
|
state?: StateValue | "state5" | "State5";
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { Children, type ReactNode } from "react";
|
import React, { Children, type ReactNode } from "react";
|
||||||
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
||||||
import SelectDropdown from "./SelectDropdown";
|
import SelectDropdown from "./SelectDropdown";
|
||||||
import SelectOption from "./SelectOption";
|
import SelectOption from "../SelectOption";
|
||||||
import type { SelectOptionData } from "./SelectInput.types";
|
import type { SelectOptionData } from "./SelectInput.types";
|
||||||
|
|
||||||
export interface SelectInputViewProps {
|
export interface SelectInputViewProps {
|
||||||
|
|||||||
+5
-3
@@ -3,8 +3,11 @@
|
|||||||
import { forwardRef, memo, useCallback } from "react";
|
import { forwardRef, memo, useCallback } from "react";
|
||||||
import { SelectOptionView } from "./SelectOption.view";
|
import { SelectOptionView } from "./SelectOption.view";
|
||||||
import type { SelectOptionProps } from "./SelectOption.types";
|
import type { SelectOptionProps } from "./SelectOption.types";
|
||||||
import { normalizeContextMenuItemSize } from "../../../../../lib/propNormalization";
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma: "Control / SelectOption" (TODO(figma)). Single option row rendered
|
||||||
|
* inside `SelectInput`'s dropdown menu.
|
||||||
|
*/
|
||||||
const SelectOptionContainer = forwardRef<HTMLDivElement, SelectOptionProps>(
|
const SelectOptionContainer = forwardRef<HTMLDivElement, SelectOptionProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
@@ -18,8 +21,7 @@ const SelectOptionContainer = forwardRef<HTMLDivElement, SelectOptionProps>(
|
|||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
|
const size = sizeProp;
|
||||||
const size = normalizeContextMenuItemSize(sizeProp);
|
|
||||||
const getTextSize = (): string => {
|
const getTextSize = (): string => {
|
||||||
switch (size) {
|
switch (size) {
|
||||||
case "small":
|
case "small":
|
||||||
+2
-9
@@ -1,10 +1,4 @@
|
|||||||
export type SelectOptionSizeValue =
|
export type SelectOptionSizeValue = "small" | "medium" | "large";
|
||||||
| "small"
|
|
||||||
| "medium"
|
|
||||||
| "large"
|
|
||||||
| "Small"
|
|
||||||
| "Medium"
|
|
||||||
| "Large";
|
|
||||||
|
|
||||||
export interface SelectOptionProps {
|
export interface SelectOptionProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -15,8 +9,7 @@ export interface SelectOptionProps {
|
|||||||
_e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
|
_e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
|
||||||
) => void;
|
) => void;
|
||||||
/**
|
/**
|
||||||
* Select option size. Accepts both lowercase and PascalCase (case-insensitive).
|
* Select option size.
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
*/
|
*/
|
||||||
size?: SelectOptionSizeValue;
|
size?: SelectOptionSizeValue;
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,11 @@
|
|||||||
import { memo, useCallback, useId, forwardRef } from "react";
|
import { memo, useCallback, useId, forwardRef } from "react";
|
||||||
import { SwitchView } from "./Switch.view";
|
import { SwitchView } from "./Switch.view";
|
||||||
import type { SwitchProps } from "./Switch.types";
|
import type { SwitchProps } from "./Switch.types";
|
||||||
import { normalizeState } from "../../../../lib/propNormalization";
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figma: "Control / Switch" (TODO(figma)). Animated on/off toggle switch,
|
||||||
|
* optionally paired with a trailing text label.
|
||||||
|
*/
|
||||||
const SwitchContainer = memo(
|
const SwitchContainer = memo(
|
||||||
forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => {
|
forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => {
|
||||||
const {
|
const {
|
||||||
@@ -18,8 +21,7 @@ const SwitchContainer = memo(
|
|||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
|
const state = stateProp;
|
||||||
const state = normalizeState(stateProp);
|
|
||||||
|
|
||||||
const switchId = useId();
|
const switchId = useId();
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ export interface SwitchProps extends Omit<
|
|||||||
onFocus?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
|
onFocus?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
|
||||||
onBlur?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
|
onBlur?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
|
||||||
/**
|
/**
|
||||||
* Visual state. Accepts "default"/"Default", "hover"/"Hover", "focus"/"Focus" (case-insensitive).
|
* Visual state.
|
||||||
* Figma uses PascalCase, codebase uses lowercase - both are supported.
|
|
||||||
*/
|
*/
|
||||||
state?: StateValue;
|
state?: StateValue;
|
||||||
/**
|
/**
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user