snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
This commit is contained in:
95
.claude/skills/adr-writing/SKILL.md
Normal file
95
.claude/skills/adr-writing/SKILL.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
name: adr-writing
|
||||
description: Architecture Decision Record format and rules for the developer agent. Load when the implementation introduces a structural decision worth remembering 6 months later — new pattern, new dependency, deliberate departure from convention, non-obvious schema choice. Skip for naming and file-layout micro-choices. No ADR is the default.
|
||||
---
|
||||
|
||||
# ADR — Architecture Decision Record
|
||||
|
||||
Load only when you have a structural decision to record. Default behaviour for any developer is **no ADR**.
|
||||
|
||||
## When to write one
|
||||
|
||||
Yes:
|
||||
- New pattern introduced into the codebase (not just used once — recurring).
|
||||
- New runtime dependency (npm package, new Supabase extension, external service).
|
||||
- Deliberate departure from an existing convention.
|
||||
- Non-obvious schema choice (denormalisation, computed column, soft-delete strategy).
|
||||
|
||||
No:
|
||||
- File naming, folder placement.
|
||||
- A bug fix or refactor with no structural impact.
|
||||
- Style decisions already captured by lint/prettier.
|
||||
|
||||
## File naming — namespaced by session + ticket
|
||||
|
||||
Path: `<WORKTREE_PATH>/adr/ADR-<SESSION_SHORT_ID>-<TASK-XXX>-<slug>.md`
|
||||
|
||||
- `<SESSION_SHORT_ID>` is your session id — the prefix of your `BRANCH_NAME` before the `/` (e.g. `BRANCH_NAME=58e4ec51/TASK-004` → `58e4ec51`). It scopes the ADR to this session.
|
||||
- `<TASK-XXX>` is the literal ticket id. Together with the session id it forms the namespace — there is no separate sequence number (e.g. `ADR-58e4ec51-TASK-004-supabase-rls-strategy.md`).
|
||||
- `<slug>` is kebab-case, ASCII, ≤ 40 chars.
|
||||
- One ADR per ticket is the common case. If the same ticket genuinely needs two ADRs, the distinct slugs keep them apart.
|
||||
|
||||
**Why the session+ticket namespace**: in a wave, N developers run in parallel on worktrees branched from the same base. A monotonically-incremented `ADR-NNN-…` scheme makes every worker independently pick the same next number (e.g. all land on `ADR-003`); different slugs hide the collision from `git merge`, so duplicate numbers land in `$CLAUDE_PROJECT_DIR/adr/` silently. `<TASK-XXX>` removes the intra-wave collision; the `<SESSION_SHORT_ID>` prefix removes the cross-session one — ticket numbering restarts at TASK-001 each session, so two sessions both producing a `TASK-004` would otherwise overwrite each other's ADR in the shared `$CLAUDE_PROJECT_DIR/adr/`.
|
||||
|
||||
## Source-code reference
|
||||
|
||||
One comment at the most representative line of the change, pointing at the ADR you just wrote:
|
||||
|
||||
- TS/JS/CSS: `// See adr/ADR-<SESSION_SHORT_ID>-<TASK-XXX>-<slug>.md`
|
||||
- Python/SQL/shell: `# See adr/ADR-<SESSION_SHORT_ID>-<TASK-XXX>-<slug>.md`
|
||||
|
||||
No need for more than one reference; reviewers follow the link.
|
||||
|
||||
## Commit
|
||||
|
||||
The ADR + its reference comment go in the same commit as the code they describe (WORKFLOW step 3). Subject:
|
||||
|
||||
```
|
||||
docs(TASK-XXX): ADR-<SESSION_SHORT_ID>-<TASK-XXX> <title>
|
||||
```
|
||||
|
||||
Reviewers see the ADR alongside the implementation.
|
||||
|
||||
## Template (≤ 25 lines)
|
||||
|
||||
```markdown
|
||||
# ADR-<SESSION_SHORT_ID>-<TASK-XXX> — <decision title>
|
||||
|
||||
- **Date**: YYYY-MM-DD
|
||||
- **Ticket**: TASK-XXX
|
||||
- **Session**: <SESSION_SHORT_ID>
|
||||
|
||||
## Context
|
||||
|
||||
2–4 lines on what made this decision necessary.
|
||||
|
||||
## Decision
|
||||
|
||||
1–3 lines on what was chosen.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Up to 4 bullets: what this enables, costs, locks in.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- Up to 3, one line each, with reason for rejection. If none were captured, write `- _Not captured at decision time._` — never invent.
|
||||
```
|
||||
|
||||
Keep it short. An ADR longer than 25 lines is a sign that the decision either belongs in code comments or is actually several decisions that should be split.
|
||||
|
||||
## Red Flags
|
||||
|
||||
- An ADR written for a naming, file-placement, or pure-style choice (no ADR is the default).
|
||||
- A monotonic `ADR-NNN-…` filename instead of the `ADR-<SESSION_SHORT_ID>-<TASK-XXX>-<slug>` namespace parallel workers collide on the same number.
|
||||
- An ADR file written outside the worktree, or committed separately from the code it describes.
|
||||
- `Alternatives considered` invented after the fact instead of `- _Not captured at decision time._`.
|
||||
- An ADR past 25 lines likely belongs in code comments or is several decisions.
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] The change is a genuine structural decision (new pattern, dependency, convention departure, non-obvious schema) otherwise no ADR.
|
||||
- [ ] Filename is `<WORKTREE_PATH>/adr/ADR-<SESSION_SHORT_ID>-<TASK-XXX>-<slug>.md`.
|
||||
- [ ] Exactly one source-code reference comment points at the ADR.
|
||||
- [ ] ADR + reference comment are in the same commit as the code, with the `docs(TASK-XXX):` subject.
|
||||
- [ ] Body follows the template and is ≤ 25 lines.
|
||||
61
.claude/skills/backend-dev/SKILL.md
Normal file
61
.claude/skills/backend-dev/SKILL.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: backend-dev
|
||||
description: Coding practices for backend development in Atomic CRM. Use when deciding whether backend logic is needed, or when creating/modifying database migrations, views, triggers, RLS policies, edge functions, or custom dataProvider methods that call Supabase APIs.
|
||||
---
|
||||
|
||||
# Backend Development
|
||||
|
||||
Reference for backend conventions. There is no custom backend server all
|
||||
server-side logic uses Supabase: PostgreSQL (tables, views, triggers, RLS),
|
||||
Auth API, Storage, and Edge Functions. Read the relevant section before writing backend code, then check the Red Flags and Verification list.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Deciding *whether* a change needs backend logic at all (default: prefer the frontend).
|
||||
- Creating or modifying database views, triggers, or RLS policies.
|
||||
- Writing or changing a Supabase edge function, or a custom dataProvider method that calls Supabase.
|
||||
|
||||
For the SQL migration mechanics themselves, see `Skill({skill: "writing-migrations"})`.
|
||||
For the frontend side of a dataProvider method, see `Skill({skill: "frontend-dev"})`.
|
||||
|
||||
## Deciding where logic belongs
|
||||
|
||||
Prefer frontend-only solutions via custom dataProvider methods calling the PostgREST API.
|
||||
|
||||
When backend logic is needed:
|
||||
|
||||
- **Aggregation/read optimization**: Create a database view (`CREATE OR REPLACE VIEW` in a new migration). PostgREST exposes views like tables. When underlying table columns change, update the `contacts_summary` and `companies_summary` views too.
|
||||
- **Complex mutations** (multi-table writes): Create a Supabase edge function in Deno. Stored procedures via RPC are less preferred (code lives in migrations, harder to maintain). On the frontend, expose the edge function as a custom dataProvider method (using `httpClient(`${supabaseUrl}/functions/v1/<name>`)`) and call it via react-query. (e.g. `salesCreate()` → `/functions/v1/users`, `mergeContacts()` → `/functions/v1/merge_contacts`)
|
||||
|
||||
## Edge function conventions
|
||||
|
||||
- Shared utils in `supabase/functions/_shared/` — reuse `authentication.ts`, `supabaseAdmin.ts`, `cors.ts`, `utils.ts`
|
||||
- Follow the middleware chain pattern: CORS preflight → `authenticate()` → handler
|
||||
- `verify_jwt = false` in config.toml, so JWT validation is manual via `authenticate()`
|
||||
|
||||
## Other conventions
|
||||
|
||||
- New tables need RLS policies and the auto-set `sales_id` trigger (see migration `20260108160722`)
|
||||
|
||||
## RLS enforcement pitfalls (security-critical)
|
||||
|
||||
- A trigger/function that **counts rows to enforce a limit** (capacity, quota, balance, subscription depletion) must be `SECURITY DEFINER` with a fixed `search_path`, or delegate to one. A `SECURITY INVOKER` count runs under the caller's RLS, sees only the rows that caller may read, under-counts, and the limit silently never fires (e.g. `session_booked_count` is DEFINER for exactly this reason).
|
||||
- A `WITH CHECK` clause must constrain **every column a non-admin may set**, not just ownership. `with check (contact_id = current_user_contact())` alone lets the caller forge any other column (`status`, `type`, amounts, flags) via PostgREST — privilege escalation / payment bypass. Whitelist allowed values in the policy, a trigger, or a `CHECK` constraint; never trust a client-chosen `status`/`type`.
|
||||
|
||||
## Red Flags
|
||||
|
||||
- Reaching for an edge function or RPC where a custom dataProvider method against PostgREST would do.
|
||||
- Adding a column to a table that feeds `contacts_summary`/`companies_summary` without updating the view.
|
||||
- A new table without RLS policies or the `sales_id` trigger.
|
||||
- A row-counting limit enforced by a `SECURITY INVOKER` function it under-counts and never fires.
|
||||
- A `WITH CHECK` that constrains only ownership while leaving `status`/`type`/amounts client-settable.
|
||||
- An edge function handler that runs before `authenticate()` in the middleware chain.
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] The logic genuinely needs the backend no frontend-only path was available.
|
||||
- [ ] New/changed views keep `contacts_summary` and `companies_summary` in sync.
|
||||
- [ ] New tables have RLS policies and the `sales_id` trigger.
|
||||
- [ ] Any limit-enforcing count runs `SECURITY DEFINER` with a fixed `search_path`.
|
||||
- [ ] Every column a non-admin can set is constrained by policy, trigger, or `CHECK`.
|
||||
- [ ] Edge functions follow CORS → `authenticate()` → handler.
|
||||
101
.claude/skills/delete-initial-resource/SKILL.md
Normal file
101
.claude/skills/delete-initial-resource/SKILL.md
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
name: delete-initial-resource
|
||||
description: Remove one or more of the initial CRM resources (contacts, companies, deals, tags, tasks) from the codebase. Use when the user asks to delete, remove, or strip out one or several of these built-in resources. Runs the delete-initial-resource.ts script to drop each resource's own folder, then guides cleanup of every file that references them.
|
||||
---
|
||||
|
||||
# delete-initial-resource
|
||||
|
||||
## Overview
|
||||
|
||||
Removes one or more of the five initial Atomic CRM resources (`contacts`, `companies`, `deals`, `tags`, `tasks`) and every reference to them. **Irreversible confirm the target(s) with the user first; rely on git to recover.** Exit criterion: `make typecheck && make lint` is clean and no live reference to the deleted resource(s) survives in `src/` or `supabase/`.
|
||||
|
||||
## When to Use
|
||||
|
||||
- The user asks to delete, remove, or strip out one or several built-in CRM resources.
|
||||
- Not for *custom* entities added during setup only the five built-ins above.
|
||||
|
||||
For the backend migration mechanics, see `Skill({skill: "backend-dev"})` and `Skill({skill: "writing-migrations"})`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Confirm the target(s)**, then read each one's file before editing:
|
||||
- `contacts` → [`contacts.md`](contacts.md) — spine; **confirm cascade scope first**
|
||||
- `companies` → [`companies.md`](companies.md) — spine/link; **confirm cascade scope first**
|
||||
- `deals` → [`deals.md`](deals.md)
|
||||
- `tags` → [`tags.md`](tags.md)
|
||||
- `tasks` → [`tasks.md`](tasks.md)
|
||||
2. **Run the script** (below) — deletes each `src/components/atomic-crm/<resource>/` folder and prints the dependent files to clean.
|
||||
3. **Clean each dependent file** using the "Shapes" patterns below + the per-resource file.
|
||||
4. **Verify** (below).
|
||||
|
||||
Deleting several at once: clean from the most-coupled resource outward; clean a shared file **once** for both; and reconcile interacting union guidance — deleting both `contacts` and `deals` removes the notes/activity-log reference *entirely* rather than each narrowing it, so don't half-apply each file's "narrow the union" note.
|
||||
|
||||
## The script
|
||||
|
||||
```bash
|
||||
node .claude/skills/delete-initial-resource/delete-initial-resource.ts <resource> [<resource> ...]
|
||||
```
|
||||
|
||||
Validates the names, deletes each resource's folder, and prints the **merged, de-duplicated dependent-file list** (files inside a just-deleted folder are dropped). It does **not** edit those files — that's your job. Each entry in the script's `dependentFiles` map has a `//` comment saying *what* to remove; read it alongside the printed list, and update the map if the codebase grows new references.
|
||||
|
||||
## Shapes a resource takes
|
||||
|
||||
A resource is rarely just a folder. The later shapes are invisible to a `\b<resource>\b` grep:
|
||||
|
||||
- **Standalone** — imports, JSX, routes, menu/nav, dashboard widgets, the `<Resource>` entry in `root/CRM.tsx`. Wrapper components *outside* the folder that only render the resource: delete outright, then clean importers.
|
||||
- **Field/column on another record** — fans out far wider: the `types.ts` type, `providers/commons/mergeContacts.ts`, CSV/JSON import, i18n, stories/tests, `StoryWrapper` builders, and the sample CSVs (header **and** every row). For a *link* column, decide per-link: orphaned shell (typed, defaulted) or drop — remove the UI either way.
|
||||
- **Aggregated `nb_<resource>`** (from the `_summary` views) lands on a record type, read across shows/lists/filters/generators. **Invisible to a `\b<resource>\b` grep** (the `_` is a word char) — grep `nb_<resource>` and the denormalized `company_name` separately.
|
||||
- **Shared subsystems** — notes (`reference: "contacts" | "deals"`) and the activity log (`"company" | "contact" | "deal" | "all"` union). Narrow the union, delete the per-type render components (`ActivityLog*Created.tsx`) + dead branches, prune the `providers/commons/activity.ts` fetcher + `consts.ts`/`types.ts` members. Surviving `=== "<other>"` checks stay valid — only the deleted member's branches go.
|
||||
- **Config props** (`taskTypes`, `dealStages`, `companySectors`) live in `root/defaultConfiguration.ts`, `ConfigurationContext.tsx`, `CRM.tsx` (default + jsdoc + store seed), `App.tsx`, and `SettingsPage.tsx` (the `<Card>` **and** the section-list entry). **Not always named after the resource** — grep the settings `<Card>` for other keys (Deals owned `currency`) and flag borderline-general ones rather than dropping them silently.
|
||||
- **Custom dataProvider methods** live in **both** providers; `CrmDataProvider` derives from the supabase one, so removing them there drops them from the type. Also clean the lifecycle-callback blocks in both providers, the `sales` `beforeDelete` reassignment, the supabase `<resource> -> <resource>_summary` `getList` routing, and the FakeRest generators (which *drive* record creation).
|
||||
- **JSON importer** (`misc/useImportFromJson.ts`) treats `contacts`/`companies`/`notes`/`tasks` as top-level types (`import<X>` fn, `$.<x>.*` path, a `TYPES` arm + switch, guards, `stats`/`failedImports`/`idsMaps` keys). Mirror in `ImportPage.tsx` + `import-sample.json`; the CSV importer (`useContactImport.tsx`) has parallel mappings.
|
||||
- Check `useCallback`/`useMemo` deps — a stale `getTags`/`getCompanies` there is invisible until `tsc`.
|
||||
|
||||
## Backend (the script only touches frontend)
|
||||
|
||||
See the `backend-dev` skill for the migration workflow. In `supabase/schemas/`:
|
||||
|
||||
- Drop/adjust the table + view, generate a migration (`npx supabase db diff --local -f remove_<resource>`); update `contacts_summary`/`companies_summary` if they aggregated it.
|
||||
- **`activity_log`** is a `UNION ALL` with the same column set per branch — remove the resource's columns from **every** branch (keep them aligned), delete its dedicated branch(es), and drop the matching snake→camel rename in the supabase `getList("activity_log")`.
|
||||
- `06_grants.sql` grants the table **and** its `<resource>_id_seq` (drop both). `05_policies.sql` has an RLS-enable line **and** `create policy` statements (drop both).
|
||||
- A resource used inside a SQL function (`merge_contacts`): drop those lines + the unused local var. Editing `02_functions.sql` regenerates the whole function (expected) — renumber leftover step comments.
|
||||
- **Orphan check:** deleting triggers can orphan helpers — but verify shared callers first (`get_domain_favicon` survives a `companies` delete because contact avatars also call it).
|
||||
- **Edge functions** are separate and easy to miss: `merge_contacts/`, `_shared/db.ts` (Kysely types), `postmark/` (inbound email), and `mcp/` (an AI subsystem whose depth varies per resource). When a surviving function loses a capability, **flag it to the user**.
|
||||
|
||||
## Rationalizations
|
||||
|
||||
| Rationalization | Reality |
|
||||
|---|---|
|
||||
| "I deleted the folder, the resource is gone." | A resource is also fields on other records, `nb_<resource>` aggregates, shared subsystems, config props, dataProvider methods, and SQL — the folder is the easy part. |
|
||||
| "A `\b<resource>\b` grep came back clean, so it's clean." | `nb_<resource>` and denormalized `company_name` are invisible to that grep (`_` is a word char). Grep them separately. |
|
||||
| "I'll remove the i18n key from English only." | The French catalog is type-checked against English — a one-sided removal is a `tsc` error. Remove from both or neither. |
|
||||
| "The script edits the dependent files for me." | The script only deletes folders and prints the list. Editing every dependent file is your job. |
|
||||
| "Typecheck passes, so the deletion is complete." | `tsc` misses broken views/functions/grants. Reset the DB and grep for live references too. |
|
||||
|
||||
## Red Flags
|
||||
|
||||
- Editing files inside `supabase/migrations/` (never — they are append-only history).
|
||||
- A one-sided i18n key removal (English without French, or vice versa).
|
||||
- Relying on a single `\b<resource>\b` grep and skipping `nb_<resource>` / `company_name`.
|
||||
- Deleting a shared SQL helper or edge-function capability without checking surviving callers.
|
||||
- Deleting several resources but half-applying each file's "narrow the union" note.
|
||||
- Skipping the `db reset` check when Supabase is running.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
make typecheck && make lint
|
||||
```
|
||||
|
||||
Resolve whatever `tsc` surfaces (the dependent-file list is a guide, not a guarantee). Then `grep -rniE "\b<resource>\b"` over `src/`+`supabase/`, plus **separate** greps for `nb_<resource>`, `company_name`, and config-prop names (`<resource>Types`, `companySectors`, `currency`). Watch for benign/substring false positives (each resource file lists its own); never edit `supabase/migrations/`.
|
||||
|
||||
- [ ] `make typecheck && make lint` is clean.
|
||||
- [ ] `\b<resource>\b`, `nb_<resource>`, `company_name`, and config-prop greps return only benign matches.
|
||||
- [ ] i18n keys removed from both catalogs, or neither.
|
||||
- [ ] Backend: table/view/policies/grants/sequences and any function references handled in `supabase/schemas/` (not `migrations/`).
|
||||
- [ ] If Supabase is running, `npx supabase db reset --local` replays cleanly.
|
||||
- [ ] Any lost edge-function capability flagged to the user.
|
||||
|
||||
**i18n is all-or-nothing:** `frenchCrmMessages.ts` is type-checked against the type derived from `englishCrmMessages.ts`, so remove a key from **both** catalogs or **neither** (a one-sided removal is a `tsc` error). Dead `resources.<x>.*` keys are harmless — flag rather than force. `frenchCrmMessages.ts` uses literal `…` escapes; if an exact-string `Edit` fails on a block spanning one, fall back to `sed -i '<from>,<to>d'`.
|
||||
|
||||
If Supabase is running, `npx supabase db reset --local` replays the chain + `seed.sql` and catches a broken view/function/grant `db diff` misses (benign `index "…_pkey" does not exist` NOTICEs expected). On Node 22 (see `.nvmrc`), `make test` (vitest) is also available.
|
||||
26
.claude/skills/delete-initial-resource/companies.md
Normal file
26
.claude/skills/delete-initial-resource/companies.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Deleting `companies`
|
||||
|
||||
A **spine/link** resource: it owns the `companies` table *and* is a `company_id` link on **both** `contacts` and `deals`, its `companies_summary` view aggregates `nb_contacts`/`nb_deals`, and it adds a **denormalized `company_name`** to the `Contact` type. So it fans out far wider than its folder (≈50 frontend files + backend).
|
||||
|
||||
## Confirm cascade scope first
|
||||
**Remove `company_id` fully** — drop the column from the `Contact`/`Deal` types, the CSV import/export, `mergeContacts`, the fakerest generators, and i18n. This full removal is what makes the deletion large.
|
||||
|
||||
## Frontend specifics
|
||||
- **Activity log** — a dedicated `company.created` branch (delete `ActivityLogCompanyCreated.tsx` outright + the branch in `ActivityLogIterator.tsx` + `COMPANY_CREATED` in `consts.ts`/`types.ts`) **and** a `"company"` member in the context union. Narrowing the union turns the `=== "company"` checks in `ActivityLogContactCreated`/`ActivityLogContactNoteCreated`/`ActivityLogDealCreated`/`ActivityLogDealNoteCreated` into type errors — prune those branches (the `company_id` "to/at `<company>`" ReferenceField + the `CompanyAvatar` in the deal-note header). `ActivityLog.tsx` has a `companyId` prop + `company_id` filter; `providers/commons/activity.ts` has a `getNewCompanies` fetcher + `companyId` param.
|
||||
- **`company_id` link** on contacts (`ContactInputs` selector, `ContactShow`/`ContactListContent` avatar+name, the exporter's `fetchRelatedRecords` + the `company` CSV column, `contactModel.ts`'s vCard `ORG:` line, `ExportVCardButton.tsx`) and on deals (`DealInputs` required selector, `DealCard`/`DealShow`/`DealEdit` avatar, `DealList` filter), plus the note-header `CompanyAvatar` in `notes/Note.tsx`.
|
||||
- **Denormalized `company_name`** (from `companies_summary`'s `c.name as company_name`) — read in `ContactOption.tsx`, `deals/ContactList.tsx`, `dashboard/HotContacts.tsx`, and the `position_at_company` rendering. **Invisible to a `\bcompan(y|ies)\b` grep** — grep `company_name` separately.
|
||||
- **`companySectors` config prop** — see SKILL.md "Shapes a resource takes" for the standard config-prop locations. Companies-specific: the `<Card>` holds only a `companySectors` `ArrayInput` (no `validateItemsInUse`), and it's also read in `useImportFromJson.ts` to validate an imported `sector`.
|
||||
- **Providers** — supabase `getList`/`getOne` route `companies -> companies_summary` (drop both); the contacts FTS list adds `company_name`. FakeRest has `processCompanyLogo` (favicon), `fetchAndUpdateCompanyData` (stamps `company_name` onto contacts, called from the contacts `beforeCreate`/`beforeUpdate`), `updateCompany` (maintains `nb_contacts`/`nb_deals` from the contacts/deals `afterCreate`/`afterDelete`), the whole `companies` callback block, the `companies` arm of the `sales` `beforeDelete` reassignment, and the `getActivityLog(dataProvider, companyId, salesId)` call (drop the `companyId` arg + the `companyId`/`company_id` filter plumbing in `providers/commons/activity.ts`). Delete `providers/commons/getCompanyAvatar.ts` + test.
|
||||
- **FakeRest generators are deeply company-coupled** — `dataGenerator/companies.ts` deleted outright + dropped from `index.ts` (mind the order: sales → tags → [companies] → contacts → …) and `types.ts` (`Db.companies`). `contacts.ts` *picks a company* to derive `company_id`/`company_name`/`sales_id`/`first_seen` (+ a per-company `maxContacts` cap), and `deals.ts` *picks a company* then filters contacts by `company_id` for `contact_ids` — rewrite both to pick a random `db.sales` + `randomDate()` (and random contacts for deals). The `faker` `company` namespace import is unrelated — keep it.
|
||||
- **JSON importer** treats `companies` as a top-level type (`importCompany`, `$.companies.*`, `CompanyImport`/`isCompany`, `mapSizeToCategory`, `companySectors` validation, stats/idsMaps keys) + the `company_id` mapping on imported contacts. Mirror in `ImportPage.tsx` + `import-sample.json`. CSV importer (`useContactImport.tsx`) has a `getCompanies` cache + a `company` column → `company_id`.
|
||||
- **Mislabeled i18n:** `deals/OnlyMineInput.tsx` uses `resources.companies.filters.only_mine` ("Only companies I manage") even though it filters deals — re-point it to a deals key, don't delete the input.
|
||||
|
||||
## Backend
|
||||
- Drop the `companies` table + `companies_id_seq` grant + RLS-enable line + its 4 policies + table grants. Drop `company_id` on **both** `contacts` and `deals` (FKs `contacts_company_id_fkey`/`deals_company_id_fkey` + indexes `contacts_company_id_idx`/`deals_company_id_idx`). Drop the `companies_summary` view (+ grant). Update `contacts_summary`: drop `co.company_id`, the `c.name as company_name` column, the `left join public.companies`, and narrow the `group by` to `co.id`.
|
||||
- `activity_log` view carries the company pattern (a `company` column on **every** branch **and** a dedicated `company.created` branch) — remove the `company` + `company_id` columns from all branches (the `contactNote`/`dealNote` branches only joined a table to fetch `company_id`, so drop those now-unused joins) and delete the branch.
|
||||
- Drop the company-only function `handle_company_saved` (favicon-from-website trigger) + its `company_saved` trigger + grant, and the `set_company_sales_id_trigger`. Drop the `company_id` line from `merge_contacts`. **Orphan subtlety:** `handle_company_saved` calls `get_domain_favicon`, but so does `get_avatar_for_email` (contact avatars) — so it is **not** orphaned; **keep** `get_domain_favicon` and the `favicons_excluded_domains` table.
|
||||
- **Edge functions:** unlike `contacts` (delete the whole function), **keep** `postmark/` and surgically remove the company bits — it auto-creates a company from the sender's email domain (`getOrCreateCompanyFromDomain` in `addNoteToContact.ts`, `extractCompanyName` in `extractMailContactData.ts`) and links the new contact via `company_id`. Delete `getOrCreateCompanyFromDomain`, drop the `domain`/`companyName`/`website` params threaded through `getOrCreateContactFromEmailInfo`/`addNoteToContact`/`index.ts`, drop `company_id` from the contact insert, remove `extractCompanyName` + the `companyName`/`website` output fields + the now-unused `tldts` import from `extractMailContactData.ts`, and update the postmark tests (`addNoteToContact.test.ts`, `extractMailContactData.test.ts`). **Flag that auto-company-creation on inbound email is gone but contact+note creation still works.** Elsewhere: `_shared/db.ts` declares `company_id` on **both** `ContactsTable` and `DealsTable` (no dedicated `CompaniesTable`); `merge_contacts/index.ts` merges `company_id` in `mergeContactData`; `mcp/index.ts` + `mcp/validateSql.test.ts` mention `companies`/`companies_summary` in prompt examples/fixtures (swap for a surviving table). No `config.toml` block changes are needed (postmark + merge_contacts both survive).
|
||||
|
||||
## grep — benign matches to leave
|
||||
- The `faker` `company` namespace import in `dataGenerator/contacts.ts` (used for `bsAdjective()` titles), the `reference="companies"`/`source="company_id"` JSDoc examples in the **mutable** `admin/reference-input.tsx`/`admin/list.tsx`, and email-domain fixtures like `@company.com`/`original.recipient@company.com` in test bodies.
|
||||
- Dead i18n keys (`resources.companies.*`, `position_at_company`, `you_added_company`/`at_company`, `crm.settings.companies.*`) — flag, don't force (both catalogs or neither). `test-data/contacts.csv` keeps `company_id`/`company_name`/`company` columns — the importer ignores them, so leaving them is harmless; dropping means editing the header **and** all ~500 rows — flag it.
|
||||
29
.claude/skills/delete-initial-resource/contacts.md
Normal file
29
.claude/skills/delete-initial-resource/contacts.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Deleting `contacts`
|
||||
|
||||
**The spine of the CRM and the widest deletion of the five.** Notes attach to `contacts | deals`, tasks belong only to contacts, `tags` is a field on `Contact`, companies aggregate `nb_contacts`, deals link `contact_ids`, and the activity-log + JSON-import + merge subsystems all revolve around it. Removing it orphans notes/tasks/tags.
|
||||
|
||||
## Confirm cascade scope first
|
||||
Ask the user how far to cascade — the answer changes the whole job:
|
||||
- **contacts only** — leave notes/tasks/tags as compiling shells (narrowing the notes union to `"deals"` keeps notes valid as long as deals exists; tasks/tags become orphaned shells), or
|
||||
- **also delete the now-orphaned subsystems**.
|
||||
- And whether to touch the backend.
|
||||
|
||||
## Frontend specifics
|
||||
- **Shared subsystems** — narrow the notes union to `"deals"` and the activity-log union to drop `"contact"`. Delete `ActivityLogContactCreated.tsx` + `ActivityLogContactNoteCreated.tsx` outright; drop `getNewContactsAndNotes` in `providers/commons/activity.ts`.
|
||||
- **Typing landmine:** `DealNote.status` is typed `undefined` (a deliberate ContactNote-compat marker in `types.ts`), so retyping `NoteInputs`'s `useFormContext<ContactNote | DealNote>` down to `<DealNote>` collapses `status` to `never` and `tsc` rejects the status-hydration `setValue("status", …)`. Widen it back with `useFormContext<Omit<DealNote, "status"> & { status?: string }>()`.
|
||||
- **Contact-only *mobile* note components** (dead once contacts is gone) — delete outright: `NoteCreateSheet.tsx`, `NoteEditSheet.tsx`, `NoteShowPage.tsx`, `NotesIteratorMobile.tsx`, `NoteInputsMobile.tsx` (+ their stories/tests). Drop the `NotesIteratorMobile` re-export from `notes/index.ts` and clean importers (`MobileNavigation.tsx`, the mobile `<Resource name="contacts">` note route in `CRM.tsx`).
|
||||
- **Links to contacts:** `deals.contact_ids` (a selector in `DealInputs.tsx`, a per-deal `<ContactList/>` in `DealShow.tsx` + the standalone `deals/ContactList.tsx` to delete, the "create a contact first" gate in `DealEmpty.tsx`) and `tasks.contact_id` (the whole task contact-linking UI: `AddTask`, `Task`, `TaskCreateSheet`, `TaskEditSheet`, `TaskFormContent`'s `selectContact`, the `showContact`/`filterByContact` threading in `TasksIterator`/`TasksListFilter`/`TasksListByDueDate`). Decide per-link: orphaned shell (`contact_ids: Identifier[]`, default `[]`) vs drop.
|
||||
- **Custom method:** `mergeContacts(sourceId, targetId)` in both providers (FakeRest → `providers/commons/mergeContacts.ts`, delete it; supabase → the `merge_contacts` edge function). Also `processContactAvatar`/`getContactAvatar` (delete `providers/commons/getContactAvatar.ts` + test), the `contacts`/`contact_notes` + `tasks` `nb_tasks` callbacks, and the supabase `contacts -> contacts_summary` routing + `contact_note -> contactNote` remap.
|
||||
- **Wrapper components** outside the folder, delete outright: `dashboard/HotContacts.tsx` (contacts widget), `dashboard/DashboardStepper.tsx` (contact-onboarding wizard). `Dashboard.tsx`/`MobileDashboard.tsx` gate the whole render on contact/contactNote counts via the stepper — drop that gating. `LatestNotes.tsx` mixes contact_notes + a `<Contact>` renderer; `dashboard/TasksList.tsx` has an `<AddTask selectContact/>`.
|
||||
- **JSON importer** is heavily contact-coupled (`importContact`/`importNote` for `contact_notes`, `contactGender`, stats/idsMaps); also `import-sample.json` (drop the contacts + notes sections). `ContactOption.tsx` is a shared option renderer — delete it.
|
||||
- FakeRest generators: delete `contacts.ts`, `contactNotes.ts`, `finalize.ts` outright; clean `index.ts`/`types.ts`; `deals.ts` (contact_ids), `tasks.ts` (contact_id + nb_tasks), `companies.ts` (nb_contacts seed).
|
||||
- Layout/login: `Header.tsx`, `MobileNavigation.tsx`, `SignupPage.tsx` (`redirectTo: "/contacts"`), `StoryWrapper.tsx` (`buildContact` + the contacts/contact_notes db keys).
|
||||
|
||||
## Backend
|
||||
- Drop the `contacts` + `contact_notes` tables, the `tasks.contact_id` column (FK + index), the `contacts_summary` view, and the functions `merge_contacts`, `handle_contact_saved`, `handle_contact_note_created_or_updated` outright — plus their triggers/policies/grants and the `contacts_id_seq` / `"contactNotes_id_seq"` grants.
|
||||
- **Orphaned helpers:** `get_avatar_for_email` (only called by `handle_contact_saved`) and `lowercase_email_jsonb` (only wired to the contact email trigger) — remove them + grants.
|
||||
- `activity_log` view carries the contact pattern (a `contact` + `contact_note` column on every branch **and** two dedicated branches `contact.created`/`contactNote.created`) — same shape as deals.
|
||||
- **Edge functions (deepest of the five):** delete the whole `merge_contacts/` function (+ its `[functions.merge_contacts]` block in `config.toml`) and the whole `postmark/` inbound-email function (+ its `[functions.postmark]` block) — this leaves the inbound-email settings UI in `ProfilePage`/`SettingsPageMobile` and the `POSTMARK_*`/`VITE_INBOUND_EMAIL` env vars hollow; **flag that to the user**. In `_shared/db.ts` drop `ContactsTable`/`ContactNotesTable`, the `tasks.contact_id` field, and their `Database` registrations. In `mcp/`: `contacts` (not tasks) drives `CRM_BASE_URL` — the `display_task_list` `contact_name`/`contact_id` schema fields and `taskListUi.ts`'s contact-link rendering exist only to deep-link task→contact, so removing them makes `CRM_BASE_URL` (in `mcp/index.ts`, the `__CRM_BASE_URL__` HTML placeholder, and `supabase/functions/.env`) dead — drop it. Rewrite the `contacts`/`contacts_summary` mentions in the `get_schema`/`query`/`mutate` prompt examples and swap the table name in `mcp/validateSql.test.ts` fixtures for a surviving table.
|
||||
|
||||
## grep — benign matches to leave (intentional)
|
||||
- `resources.companies.field_categories.contact` (a company "Company info" section label in `CompanyInputs.tsx`/`CompanyAside.tsx`, not the contacts resource), the `resource="contacts"` JSDoc examples in `misc/CreateSheet.tsx`/`EditSheet.tsx`, and the now-unused `resources.contacts.*` i18n keys (optional/large — flag, don't force). `supabase/migrations/` keeps historical references — never edit them.
|
||||
19
.claude/skills/delete-initial-resource/deals.md
Normal file
19
.claude/skills/delete-initial-resource/deals.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Deleting `deals`
|
||||
|
||||
`deals` owns the `deals` + `deal_notes` tables but is woven into **two shared subsystems** (notes, activity log), adds an aggregated `nb_deals` to `companies_summary`, ships **three** config props plus a non-eponymous one, and has a custom dataProvider method — so it fans out almost as wide as a column removal.
|
||||
|
||||
## Frontend specifics
|
||||
- **Shared subsystems** — narrow the notes union to `"contacts"` and the activity-log union to drop `"deal"`; prune the `=== "deals"`/`deal_id`/`DealNote` branches (the `=== "contacts"` checks stay valid). Delete `ActivityLogDealCreated.tsx` + `ActivityLogDealNoteCreated.tsx` outright; drop `getNewDealsAndNotes` in `providers/commons/activity.ts` and `DEAL_CREATED`/`DEAL_NOTE_CREATED` + `ActivityDeal*` in `consts.ts`/`types.ts`. Drop the `"deals"` reference test in `NoteInputs.test.tsx`.
|
||||
- **Config props** — `dealStages`/`dealCategories`/`dealPipelineStatuses` **plus `currency`**, which is deal-scoped but NOT named after the resource (it lived in the Deals `<Card>`; readers are `DealsChart`/`DealsPipeline`/`CompanyShow`'s deal iterator). Removing `currency` is a judgement call — flag it. The Deals `<Card>` also carries a `validateItemsInUse` helper — exported, unit-tested in `SettingsPage.test.ts` (delete that test file outright), backed by a `crm.settings.validation` i18n block; delete all three.
|
||||
- **Custom method** — `unarchiveDeal(deal)` in **both** providers (drops from `CrmDataProvider` automatically via the supabase provider's return type).
|
||||
- **Wrapper components** outside the folder, delete outright: `dashboard/DealsChart.tsx`, `dashboard/DealsPipeline.tsx`. `Dashboard.tsx` keeps both an import **and** a `{totalDeal ? <DealsChart/> : null}` JSX site — remove both. `LatestNotes.tsx` fetches `deal_notes` + renders `<Deal/>`.
|
||||
- **`nb_deals` readers** (invisible to `\bdeals?\b` grep): `CompanyShow.tsx` (deals tab), `CompanyCard.tsx` (badge). `ContactMergeButton.tsx` shows a `dealsCount` preview.
|
||||
- i18n: `resources.deals`, `nb_deals`, activity/dashboard/`settings.deals`/`settings.validation` keys; `i18nProvider.test.ts` asserts a `resources.deals.*` key.
|
||||
|
||||
## Backend
|
||||
- Drop the `deals` + `deal_notes` tables, grants, policies, sequences. Update `companies_summary` (drop `count(distinct d.id) as nb_deals` + the `left join public.deals`).
|
||||
- `activity_log` view: `deals` contributed a `deal` + `deal_note` column to **every** branch **and** two dedicated branches (`deal.created`, `dealNote.created`) — remove both, keep column lists aligned, and drop the `deal_note → dealNote` rename in the supabase provider's `getList("activity_log")`.
|
||||
- **MCP:** **no** dedicated MCP App — only the `DealsTable` Kysely interface in `_shared/db.ts`, the deal-reassignment step in `merge_contacts/index.ts` (its removal leaves the `sql` import unused — drop it), and deal mentions in the `query`/`mutate` **prompt-text examples** in `mcp/index.ts` (rewrite the examples, don't just delete lines).
|
||||
|
||||
## grep — benign matches to leave
|
||||
- `deals` matches `ideal` (an English comment in `RelativeDate.tsx`) and the French `affaires`/`Chiffre d'affaires` (= revenue, not a deal, in `frenchCrmMessages.ts`) — filter them out and don't blind-delete French lines.
|
||||
350
.claude/skills/delete-initial-resource/delete-initial-resource.ts
Executable file
350
.claude/skills/delete-initial-resource/delete-initial-resource.ts
Executable file
@@ -0,0 +1,350 @@
|
||||
// Run with `node .claude/skills/delete-initial-resource/delete-initial-resource.ts`
|
||||
// (Node >= 22.18 strips TypeScript types natively; on older Node use `npx -y tsx <...>`).
|
||||
// `process` is declared locally because this standalone script lives outside the
|
||||
// project's tsconfig and so doesn't pick up the global @types/node.
|
||||
declare const process: {
|
||||
cwd(): string;
|
||||
argv: string[];
|
||||
exit(code?: number): never;
|
||||
};
|
||||
import { existsSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
|
||||
// ================================ CONSTANTS ================================
|
||||
const initialResource = [
|
||||
"contacts",
|
||||
"companies",
|
||||
"deals",
|
||||
"tags",
|
||||
"tasks",
|
||||
] as const;
|
||||
const dependentFiles: ResourceFiles = {
|
||||
companies: [
|
||||
"src/components/atomic-crm/dataImport/useCompanyImport.ts",
|
||||
"src/components/atomic-crm/dataImport/companies_sample.csv",
|
||||
"src/components/atomic-crm/dataImport/useCompanyResolver.ts",
|
||||
"src/components/atomic-crm/dataImport/useImportableResources.ts",
|
||||
"src/components/atomic-crm/types.ts",
|
||||
"src/components/atomic-crm/consts.ts",
|
||||
"src/components/atomic-crm/activity/ActivityLogCompanyCreated.tsx",
|
||||
"src/components/atomic-crm/activity/ActivityLog.tsx",
|
||||
"src/components/atomic-crm/activity/ActivityLogContext.tsx",
|
||||
"src/components/atomic-crm/activity/ActivityLogIterator.tsx",
|
||||
"src/components/atomic-crm/activity/ActivityLogContactCreated.tsx",
|
||||
"src/components/atomic-crm/activity/ActivityLogContactNoteCreated.tsx",
|
||||
"src/components/atomic-crm/activity/ActivityLogDealCreated.tsx",
|
||||
"src/components/atomic-crm/activity/ActivityLogDealNoteCreated.tsx",
|
||||
"src/components/atomic-crm/providers/commons/activity.ts",
|
||||
"src/components/atomic-crm/root/defaultConfiguration.ts",
|
||||
"src/components/atomic-crm/root/ConfigurationContext.tsx",
|
||||
"src/components/atomic-crm/settings/SettingsPage.tsx",
|
||||
"src/App.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactInputs.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactShow.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactListContent.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactList.tsx",
|
||||
"src/components/atomic-crm/contacts/contactModel.ts",
|
||||
"src/components/atomic-crm/contacts/ExportVCardButton.tsx",
|
||||
"src/components/atomic-crm/contacts/useContactImport.tsx",
|
||||
"src/components/atomic-crm/contacts/Avatar.tsx",
|
||||
"src/components/atomic-crm/contacts/contacts_export.csv",
|
||||
"src/components/atomic-crm/deals/DealInputs.tsx",
|
||||
"src/components/atomic-crm/deals/DealCard.tsx",
|
||||
"src/components/atomic-crm/deals/DealShow.tsx",
|
||||
"src/components/atomic-crm/deals/DealEdit.tsx",
|
||||
"src/components/atomic-crm/deals/DealList.tsx",
|
||||
"src/components/atomic-crm/deals/OnlyMineInput.tsx",
|
||||
"src/components/atomic-crm/deals/ContactList.tsx",
|
||||
"src/components/atomic-crm/notes/Note.tsx",
|
||||
"src/components/atomic-crm/dashboard/HotContacts.tsx",
|
||||
"src/components/atomic-crm/dashboard/DealsPipeline.tsx",
|
||||
"src/components/atomic-crm/misc/ContactOption.tsx",
|
||||
"src/components/atomic-crm/misc/useImportFromJson.ts",
|
||||
"src/components/atomic-crm/misc/ImportPage.tsx",
|
||||
"src/components/atomic-crm/misc/import-sample.json",
|
||||
"src/components/atomic-crm/providers/supabase/dataProvider.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataProvider.ts",
|
||||
"src/components/atomic-crm/providers/commons/getCompanyAvatar.ts",
|
||||
"src/components/atomic-crm/providers/commons/mergeContacts.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/companies.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/index.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/types.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/contacts.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/deals.ts",
|
||||
"src/components/atomic-crm/providers/commons/englishCrmMessages.ts",
|
||||
"src/components/atomic-crm/providers/commons/frenchCrmMessages.ts",
|
||||
"src/components/atomic-crm/layout/Header.tsx",
|
||||
"src/components/atomic-crm/layout/MobileNavigation.tsx",
|
||||
"src/test/StoryWrapper.tsx",
|
||||
"test-data/contacts.csv",
|
||||
],
|
||||
contacts: [
|
||||
"src/components/atomic-crm/dataImport/useImportableResources.ts",
|
||||
"src/components/atomic-crm/dataImport/useCompanyResolver.ts",
|
||||
"src/components/atomic-crm/types.ts",
|
||||
"src/components/atomic-crm/consts.ts",
|
||||
"src/components/atomic-crm/activity/ActivityLogContactCreated.tsx",
|
||||
"src/components/atomic-crm/activity/ActivityLogContactNoteCreated.tsx",
|
||||
"src/components/atomic-crm/activity/ActivityLogIterator.tsx",
|
||||
"src/components/atomic-crm/activity/ActivityLog.tsx",
|
||||
"src/components/atomic-crm/activity/ActivityLogContext.tsx",
|
||||
"src/components/atomic-crm/providers/commons/activity.ts",
|
||||
"src/components/atomic-crm/dashboard/HotContacts.tsx",
|
||||
"src/components/atomic-crm/dashboard/DashboardStepper.tsx",
|
||||
"src/components/atomic-crm/dashboard/Dashboard.tsx",
|
||||
"src/components/atomic-crm/dashboard/MobileDashboard.tsx",
|
||||
"src/components/atomic-crm/dashboard/LatestNotes.tsx",
|
||||
"src/components/atomic-crm/dashboard/TasksList.tsx",
|
||||
"src/components/atomic-crm/companies/CompanyShow.tsx",
|
||||
"src/components/atomic-crm/companies/CompanyCard.tsx",
|
||||
"src/components/atomic-crm/companies/CompanyList.tsx",
|
||||
"src/components/atomic-crm/notes/foreignKeyMapping.ts",
|
||||
"src/components/atomic-crm/notes/NoteInputs.tsx",
|
||||
"src/components/atomic-crm/notes/NoteInputs.test.tsx",
|
||||
"src/components/atomic-crm/notes/NoteCreate.tsx",
|
||||
"src/components/atomic-crm/notes/NotesIterator.tsx",
|
||||
"src/components/atomic-crm/notes/Note.tsx",
|
||||
"src/components/atomic-crm/notes/NoteAttachments.tsx",
|
||||
"src/components/atomic-crm/notes/index.ts",
|
||||
"src/components/atomic-crm/notes/NoteCreateSheet.tsx",
|
||||
"src/components/atomic-crm/notes/NoteEditSheet.tsx",
|
||||
"src/components/atomic-crm/notes/NoteShowPage.tsx",
|
||||
"src/components/atomic-crm/notes/NotesIteratorMobile.tsx",
|
||||
"src/components/atomic-crm/notes/NoteInputsMobile.tsx",
|
||||
"src/components/atomic-crm/tasks/AddTask.tsx",
|
||||
"src/components/atomic-crm/tasks/Task.tsx",
|
||||
"src/components/atomic-crm/tasks/TaskCreateSheet.tsx",
|
||||
"src/components/atomic-crm/tasks/TaskCreateSheet.stories.tsx",
|
||||
"src/components/atomic-crm/tasks/TaskCreateSheet.test.tsx",
|
||||
"src/components/atomic-crm/tasks/TaskEditSheet.tsx",
|
||||
"src/components/atomic-crm/tasks/TaskFormContent.tsx",
|
||||
"src/components/atomic-crm/tasks/TasksIterator.tsx",
|
||||
"src/components/atomic-crm/tasks/TasksListFilter.tsx",
|
||||
"src/components/atomic-crm/tasks/TasksListFilter.test.tsx",
|
||||
"src/components/atomic-crm/tasks/TasksListByDueDate.tsx",
|
||||
"src/components/atomic-crm/deals/DealInputs.tsx",
|
||||
"src/components/atomic-crm/deals/DealShow.tsx",
|
||||
"src/components/atomic-crm/deals/ContactList.tsx",
|
||||
"src/components/atomic-crm/deals/DealEmpty.tsx",
|
||||
"src/components/atomic-crm/misc/ContactOption.tsx",
|
||||
"src/components/atomic-crm/misc/useImportFromJson.ts",
|
||||
"src/components/atomic-crm/misc/ImportPage.tsx",
|
||||
"src/components/atomic-crm/misc/import-sample.json",
|
||||
"src/components/atomic-crm/providers/commons/mergeContacts.ts",
|
||||
"src/components/atomic-crm/providers/commons/getContactAvatar.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataProvider.ts",
|
||||
"src/components/atomic-crm/providers/supabase/dataProvider.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/contacts.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/contactNotes.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/finalize.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/index.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/types.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/deals.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/tasks.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/companies.ts",
|
||||
"src/components/atomic-crm/providers/commons/englishCrmMessages.ts",
|
||||
"src/components/atomic-crm/providers/commons/frenchCrmMessages.ts",
|
||||
"src/components/atomic-crm/layout/Header.tsx",
|
||||
"src/components/atomic-crm/layout/MobileNavigation.tsx",
|
||||
"src/components/atomic-crm/login/SignupPage.tsx",
|
||||
"src/test/StoryWrapper.tsx",
|
||||
],
|
||||
deals: [
|
||||
"src/components/atomic-crm/dataImport/useDealImport.ts",
|
||||
"src/components/atomic-crm/dataImport/deals_sample.csv",
|
||||
"src/components/atomic-crm/dataImport/useImportableResources.ts",
|
||||
"src/components/atomic-crm/types.ts",
|
||||
"src/components/atomic-crm/consts.ts",
|
||||
"src/components/atomic-crm/dashboard/DealsChart.tsx",
|
||||
"src/components/atomic-crm/dashboard/DealsPipeline.tsx",
|
||||
"src/components/atomic-crm/activity/ActivityLogDealCreated.tsx",
|
||||
"src/components/atomic-crm/activity/ActivityLogDealNoteCreated.tsx",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/deals.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/dealNotes.ts",
|
||||
"src/components/atomic-crm/notes/foreignKeyMapping.ts",
|
||||
"src/components/atomic-crm/notes/NoteInputs.tsx",
|
||||
"src/components/atomic-crm/notes/NoteCreate.tsx",
|
||||
"src/components/atomic-crm/notes/Note.tsx",
|
||||
"src/components/atomic-crm/notes/NoteAttachments.tsx",
|
||||
"src/components/atomic-crm/notes/NotesIterator.tsx",
|
||||
"src/components/atomic-crm/notes/NoteInputs.test.tsx",
|
||||
"src/components/atomic-crm/activity/ActivityLogIterator.tsx",
|
||||
"src/components/atomic-crm/activity/ActivityLog.tsx",
|
||||
"src/components/atomic-crm/activity/ActivityLogContext.tsx",
|
||||
"src/components/atomic-crm/dashboard/Dashboard.tsx",
|
||||
"src/components/atomic-crm/dashboard/LatestNotes.tsx",
|
||||
"src/components/atomic-crm/providers/commons/activity.ts",
|
||||
"src/components/atomic-crm/providers/commons/mergeContacts.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataProvider.ts",
|
||||
"src/components/atomic-crm/providers/supabase/dataProvider.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/index.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/types.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/companies.ts",
|
||||
"src/components/atomic-crm/providers/commons/englishCrmMessages.ts",
|
||||
"src/components/atomic-crm/providers/commons/frenchCrmMessages.ts",
|
||||
"src/components/atomic-crm/providers/commons/i18nProvider.test.ts",
|
||||
"src/components/atomic-crm/root/defaultConfiguration.ts",
|
||||
"src/components/atomic-crm/root/ConfigurationContext.tsx",
|
||||
"src/components/atomic-crm/settings/SettingsPage.tsx",
|
||||
"src/components/atomic-crm/settings/SettingsPage.test.ts",
|
||||
"src/App.tsx",
|
||||
"src/components/atomic-crm/companies/CompanyShow.tsx",
|
||||
"src/components/atomic-crm/companies/CompanyCard.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactMergeButton.tsx",
|
||||
"src/components/atomic-crm/layout/Header.tsx",
|
||||
"src/components/atomic-crm/layout/MobileNavigation.tsx",
|
||||
"src/test/StoryWrapper.tsx",
|
||||
],
|
||||
tags: [
|
||||
"src/components/atomic-crm/types.ts",
|
||||
"src/components/atomic-crm/contacts/TagsList.tsx",
|
||||
"src/components/atomic-crm/contacts/TagsListEdit.tsx",
|
||||
"src/components/atomic-crm/contacts/BulkTagButton.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactShow.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactAside.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactListContent.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactList.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactListFilter.tsx",
|
||||
"src/components/atomic-crm/contacts/contactModel.ts",
|
||||
"src/components/atomic-crm/contacts/useContactImport.tsx",
|
||||
"src/components/atomic-crm/companies/CompanyShow.tsx",
|
||||
"src/components/atomic-crm/misc/useImportFromJson.ts",
|
||||
"src/components/atomic-crm/providers/commons/mergeContacts.ts",
|
||||
"src/components/atomic-crm/providers/commons/englishCrmMessages.ts",
|
||||
"src/components/atomic-crm/providers/commons/frenchCrmMessages.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/contacts.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/index.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/types.ts",
|
||||
"src/components/atomic-crm/contacts/ContactList.stories.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactList.test.tsx",
|
||||
"src/test/StoryWrapper.tsx",
|
||||
"src/components/atomic-crm/contacts/contacts_export.csv",
|
||||
"test-data/contacts.csv",
|
||||
],
|
||||
tasks: [
|
||||
"src/components/atomic-crm/types.ts",
|
||||
"src/components/atomic-crm/dashboard/TasksList.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactTasksList.tsx",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/tasks.ts",
|
||||
"src/components/atomic-crm/dashboard/Dashboard.tsx",
|
||||
"src/components/atomic-crm/layout/MobileNavigation.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactShow.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactShow.test.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactAside.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactListContent.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactListFilter.tsx",
|
||||
"src/components/atomic-crm/contacts/ContactMergeButton.tsx",
|
||||
"src/components/atomic-crm/companies/CompanyShow.tsx",
|
||||
"src/components/atomic-crm/misc/useImportFromJson.ts",
|
||||
"src/components/atomic-crm/misc/ImportPage.tsx",
|
||||
"src/components/atomic-crm/misc/import-sample.json",
|
||||
"src/components/atomic-crm/providers/commons/mergeContacts.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataProvider.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/index.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/types.ts",
|
||||
"src/components/atomic-crm/providers/fakerest/dataGenerator/contacts.ts",
|
||||
"src/test/StoryWrapper.tsx",
|
||||
"src/components/atomic-crm/providers/commons/englishCrmMessages.ts",
|
||||
"src/components/atomic-crm/providers/commons/frenchCrmMessages.ts",
|
||||
"src/components/atomic-crm/root/defaultConfiguration.ts",
|
||||
"src/components/atomic-crm/root/ConfigurationContext.tsx",
|
||||
"src/components/atomic-crm/settings/SettingsPage.tsx",
|
||||
"src/App.tsx",
|
||||
],
|
||||
} as const;
|
||||
const sharedDependentFiles: string[] = [
|
||||
"src/components/atomic-crm/root/CRM.tsx",
|
||||
];
|
||||
const resourceFilesPath = "src/components/atomic-crm" as const;
|
||||
|
||||
// ================================ TYPES ================================
|
||||
type InitialResource = (typeof initialResource)[number];
|
||||
type DependentFile = string;
|
||||
type ResourceFiles = Record<InitialResource, DependentFile[]>;
|
||||
|
||||
// ================================ FUNCTIONS ================================
|
||||
const main = async () => {
|
||||
const resourcesToDelete = getResources();
|
||||
const basePath = process.cwd();
|
||||
for (const resource of resourcesToDelete) {
|
||||
await deleteIsolatedFiles(resource, basePath);
|
||||
}
|
||||
returnDependentFiles(resourcesToDelete);
|
||||
};
|
||||
|
||||
// Get the resources to delete from the command line arguments (one or more),
|
||||
// validate each one, and de-duplicate.
|
||||
const getResources = (): InitialResource[] => {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0) {
|
||||
console.error(
|
||||
"Please provide at least one resource to delete (you may pass several).",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const invalid = args.filter(
|
||||
(resource) => !initialResource.includes(resource as InitialResource),
|
||||
);
|
||||
if (invalid.length > 0) {
|
||||
console.error(
|
||||
`Resource(s) ${invalid
|
||||
.map((resource) => `"${resource}"`)
|
||||
.join(", ")} do not exist. Valid resources: ${initialResource.join(
|
||||
", ",
|
||||
)}.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return [...new Set(args)] as InitialResource[];
|
||||
};
|
||||
|
||||
// Delete the resource folder and its files.
|
||||
const deleteIsolatedFiles = async (
|
||||
resource: InitialResource,
|
||||
basePath: string,
|
||||
) => {
|
||||
const folderPath = `${basePath}/${resourceFilesPath}/${resource}`;
|
||||
|
||||
if (!existsSync(folderPath)) {
|
||||
console.error(
|
||||
`Folder for resource "${resource}" does not exist. Skipping deletion of isolated files.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await rm(folderPath, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
// Return to Claude all the files dependent on the deleted resource(s).
|
||||
// When several resources are deleted at once, merge and de-duplicate their
|
||||
// dependent-file lists, then drop any file that lives inside a folder we just
|
||||
// deleted (e.g. `companies/CompanyShow.tsx` is a dependent of `contacts`, but
|
||||
// it is gone once `companies` is deleted too).
|
||||
const returnDependentFiles = (resources: InitialResource[]) => {
|
||||
const deletedFolders = resources.map(
|
||||
(resource) => `${resourceFilesPath}/${resource}/`,
|
||||
);
|
||||
|
||||
const merged = new Set<DependentFile>([
|
||||
...resources.flatMap((resource) => dependentFiles[resource]),
|
||||
...sharedDependentFiles,
|
||||
]);
|
||||
|
||||
const allDependentFilesToReturn = [...merged]
|
||||
.filter((file) => !deletedFolders.some((folder) => file.startsWith(folder)))
|
||||
.sort();
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`Dependent files for resource(s) ${resources
|
||||
.map((resource) => `"${resource}"`)
|
||||
.join(", ")}:\n`,
|
||||
allDependentFilesToReturn,
|
||||
);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
// ================================ MAIN CALL ================================
|
||||
await main();
|
||||
14
.claude/skills/delete-initial-resource/tags.md
Normal file
14
.claude/skills/delete-initial-resource/tags.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Deleting `tags`
|
||||
|
||||
The simplest of the five: `tags` is a **field** (`number[]`) on `Contact` plus a small standalone `tags` folder. No shared subsystem, no config prop, no aggregated column.
|
||||
|
||||
## Frontend specifics
|
||||
- Tag-only components under `contacts/` — delete outright: `TagsList.tsx`, `TagsListEdit.tsx`, `BulkTagButton.tsx`. Clean their consumers (`ContactShow`, `ContactAside`, `ContactListContent`, `ContactList`, `ContactListFilter`).
|
||||
- The `contacts.tags` field lives in `contactModel.ts`, `useContactImport.tsx`, `providers/commons/mergeContacts.ts`, the FakeRest generators (`dataGenerator/contacts.ts` + `index.ts` + `types.ts`), the stories/tests (`ContactList.stories.tsx`, `ContactList.test.tsx`, `StoryWrapper.tsx`), and the sample CSVs (`contacts_export.csv`, `test-data/contacts.csv`).
|
||||
- i18n: drop the `bulk_tag` block, `filters.tags`, and the `tags` resource block (both catalogs — see SKILL.md).
|
||||
|
||||
## Backend
|
||||
- Drop the `tags` table + its grants/policies/sequence. `merge_contacts` in `02_functions.sql` merges `tags` — drop those lines + the now-unused local var, and renumber step comments.
|
||||
|
||||
## grep — benign matches to leave
|
||||
- `tags` matches `stage` — filter it out (`grep -viE "stage"`).
|
||||
13
.claude/skills/delete-initial-resource/tasks.md
Normal file
13
.claude/skills/delete-initial-resource/tasks.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Deleting `tasks`
|
||||
|
||||
`tasks` looks standalone (it owns the `tasks` folder + table) but it **adds an aggregated `nb_tasks` column to `contacts_summary`** (which lands on the `Contact` type) and ships a **`taskTypes` config prop** — so it fans out almost like a column removal.
|
||||
|
||||
## Frontend specifics
|
||||
- **`nb_tasks` readers:** `CompanyShow.tsx`, `ContactListContent.tsx`, `ContactShow.tsx` (+ test), `ContactListFilter.tsx` (a `nb_tasks@gt` filter), `ContactMergeButton.tsx`, and the FakeRest provider/generators (which increment/decrement it). **Invisible to `\btasks?\b` grep** — grep `nb_tasks` separately.
|
||||
- **`taskTypes` config prop** — see SKILL.md "Shapes a resource takes". The Tasks `<Card>` has no `validateItemsInUse`.
|
||||
- **Wrapper components** outside the folder, delete outright: `dashboard/TasksList.tsx`, `contacts/ContactTasksList.tsx`.
|
||||
- i18n: drop the `tasks` resource block, `task_count`, `upcoming_tasks`, `filters.tasks`, `settings.tasks`, and the import description (both catalogs — see SKILL.md).
|
||||
|
||||
## Backend
|
||||
- Drop the `tasks` table + grants/policies/sequence.
|
||||
- **MCP edge function:** `mcp/taskListUi.ts` is a ~13 KB guest-HTML "MCP App" — delete outright. Remove the `display_task_list` + `complete_task` tools and their prompt examples in `mcp/index.ts`, and the `tasks` example in `mcp/validateSql.test.ts`. (Note: the `CRM_BASE_URL` env var is driven by the task→contact deep-link, which is really a `contacts` concern — see [`contacts.md`](contacts.md); it only goes dead if contacts is deleted too.)
|
||||
48
.claude/skills/e2e-conventions/SKILL.md
Normal file
48
.claude/skills/e2e-conventions/SKILL.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: e2e-conventions
|
||||
description: When to write e2e tests, where to put them, and how to verify them. Apply to any task touching UI, filters, forms, or interactions.
|
||||
---
|
||||
|
||||
# E2E Conventions
|
||||
|
||||
Reference for *whether* an e2e test is required, *where* it goes, and *what* it must assert. For the assertion/locator patterns inside the spec, see
|
||||
`Skill({skill: "playwright-testing"})`.
|
||||
|
||||
## When e2e tests are required
|
||||
|
||||
A change **requires** an e2e test if it touches any of:
|
||||
|
||||
- UI components or pages
|
||||
- Filters or search
|
||||
- Forms or user input
|
||||
- Interactions (click, drag, keyboard)
|
||||
|
||||
Exception: pure CSS or a DB-migration-only change. State this explicitly in the task notes.
|
||||
|
||||
## Where to put them
|
||||
|
||||
Under `e2e/`, named after the feature:
|
||||
|
||||
```
|
||||
e2e/<feature-name>.spec.ts
|
||||
```
|
||||
|
||||
If the work is tracked under a ticket id, you may prefix the file with it (e.g. `e2e/TASK-001-deal-importance.spec.ts`) to make it easy to find — but the ticket id is not required.
|
||||
|
||||
## What to verify
|
||||
|
||||
Write the spec alongside the implementation. The reviewer checks that the spec exists and asserts the right thing. CI runs it — don't run it locally yourself.
|
||||
|
||||
## Red Flags
|
||||
|
||||
- A UI/filter/form/interaction change with no `e2e/*.spec.ts` added.
|
||||
- Claiming the CSS/migration-only exception without saying so in the task notes.
|
||||
- A spec placed outside `e2e/`, or that asserts nothing user-visible.
|
||||
- Running the e2e suite locally instead of letting CI do it.
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] If the change touches UI/filters/forms/interactions, a spec exists under `e2e/`.
|
||||
- [ ] The spec is named after the feature (ticket-id prefix optional).
|
||||
- [ ] The spec asserts the right user-visible behavior, using `playwright-testing` patterns.
|
||||
- [ ] Any CSS/migration-only exemption is stated explicitly in the task notes.
|
||||
79
.claude/skills/frontend-dev/SKILL.md
Normal file
79
.claude/skills/frontend-dev/SKILL.md
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: frontend-dev
|
||||
description: Coding practices for frontend development in Atomic CRM. Use when creating or modifying React components, forms, list pages, detail views, filters, data fetching, or responsive layouts.
|
||||
---
|
||||
|
||||
# Frontend Development
|
||||
|
||||
Reference for the conventions a frontend change must follow.
|
||||
This is a reference skill, not a step sequence — read the relevant section before writing code, then verify against the checklist at the end.
|
||||
The frontend uses ra-core (react-admin headless) for data fetching, routing, and CRUD logic, with shadcn-admin-kit and shadcn/ui for the UI layer.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Creating or modifying React components, forms, list pages, or detail views.
|
||||
- Adding or changing filters, data fetching, or responsive (mobile/desktop) layouts.
|
||||
- Registering a new resource or reorganizing an existing resource folder.
|
||||
|
||||
Not for backend/schema work — see `Skill({skill: "backend-dev"})`.
|
||||
Not for theming/colors — see `Skill({skill: "shadcn-customization"})`.
|
||||
|
||||
## Component architecture
|
||||
|
||||
- Import form inputs (`TextInput`, `SelectInput`, `ReferenceInput`, etc.) from `@/components/admin/`, not from shadcn/ui directly. The admin layer wraps shadcn with ra-core integration (labels, validation, data binding).
|
||||
- Import pure UI components (`Card`, `Button`, `Badge`, `Sheet`, etc.) from `@/components/ui/`.
|
||||
- Domain configuration (deal stages, note statuses, task types, company sectors) comes from `useConfigurationContext()`, never hardcoded.
|
||||
|
||||
## Resource (CRUD) conventions
|
||||
|
||||
Each resource follows this file structure (e.g. `contacts/`):
|
||||
|
||||
- `ContactList.tsx` — list page (desktop + mobile variants)
|
||||
- `ContactShow.tsx` — detail view
|
||||
- `ContactEdit.tsx` / `ContactCreate.tsx` — form pages
|
||||
- `ContactInputs.tsx` — shared form fields reused between create and edit
|
||||
- `index.tsx` — exports `{ list, show, edit, create, recordRepresentation }`
|
||||
|
||||
Resources are registered in `root/CRM.tsx` via `<Resource name="contacts" {...contacts} />`.
|
||||
|
||||
## Data fetching
|
||||
|
||||
- For standard CRUD, use ra-core hooks: `useListContext()`, `useShowContext()`, `useGetList()`, `useGetOne()`, `useGetIdentity()`.
|
||||
- When a query or mutation isn't covered by ra-core hooks, add a custom dataProvider method and call it via `useQuery`/`useMutation` with `useDataProvider<CrmDataProvider>()` (e.g. `dataProvider.getActivityLog()` in `ActivityLog.tsx`, `dataProvider.salesCreate()` in `SalesCreate.tsx`).
|
||||
|
||||
## Forms
|
||||
|
||||
- Forms use `Form` from ra-core + `FormToolbar` for submit/cancel actions.
|
||||
- Ra-core's `Form` uses React Hook Form under the hood. Use `useFormContext()` for imperative operations (`setValue`, `reset`, `getValues`).
|
||||
- Top-level resource forms use full-page `CreateBase`/`EditBase` with `Card` (e.g. contacts), or `Dialog` (e.g. deals).
|
||||
- On mobile, inline/sub-resource forms use `CreateSheet`/`EditSheet` from `misc/` (e.g. notes, tasks).
|
||||
- Split form fields into semantic sub-components (e.g. `ContactIdentityInputs`, `ContactPositionInputs`).
|
||||
|
||||
## Filters
|
||||
|
||||
- Use `ToggleFilterButton` / `ActiveFilterButton` components for filter UI.
|
||||
- Filters apply immediately, no "apply" button.
|
||||
|
||||
## Responsive design
|
||||
|
||||
- Major pages have desktop and mobile variants. Use `useIsMobile()` to branch.
|
||||
- Desktop: 2-column grid layouts. Mobile: single column with `MobileHeader`/`MobileContent`.
|
||||
- Mobile lists use `InfiniteListBase` for scroll pagination.
|
||||
|
||||
## Red Flags
|
||||
|
||||
- Importing `TextInput`/`SelectInput`/etc. directly from `@/components/ui/` instead of `@/components/admin/`.
|
||||
- Hardcoding deal stages, note statuses, task types, or sectors instead of reading `useConfigurationContext()`.
|
||||
- A new resource folder missing one of the standard files, or not registered in `root/CRM.tsx`.
|
||||
- A bespoke fetch/axios call where a ra-core hook or a dataProvider method belongs.
|
||||
- A new top-level page with only a desktop layout and no `useIsMobile()` branch.
|
||||
- A form field component growing past the typical size instead of being split into semantic sub-components.
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] Inputs imported from `@/components/admin/`, pure UI from `@/components/ui/`.
|
||||
- [ ] Domain options come from `useConfigurationContext()` — nothing hardcoded.
|
||||
- [ ] New resources follow the file structure and are registered in `root/CRM.tsx`.
|
||||
- [ ] Data access uses ra-core hooks, or a dataProvider method via react-query.
|
||||
- [ ] Pages touching major UI have both desktop and mobile variants.
|
||||
- [ ] If the change touches UI/forms/filters/interactions, an e2e test exists (see `Skill({skill: "e2e-conventions"})`).
|
||||
38
.claude/skills/grill-me/SKILL.md
Normal file
38
.claude/skills/grill-me/SKILL.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: Interrogate a vague or broad feature request BEFORE dispatching the harness orchestrator, to surface what the user has not anticipated. Use in the main thread when a #harness request is underspecified, when scope seems broad, or when a plan would come out too generic. Not for the orchestrator (headless, cannot ask).
|
||||
---
|
||||
|
||||
# grill-me
|
||||
|
||||
Ask probing questions about the request before any plan or code, to surface
|
||||
what the user has NOT anticipated, not just confirm what they already said.
|
||||
|
||||
## How
|
||||
|
||||
- **One question at a time**, in prose. Never a multiple-choice menu, never a
|
||||
batch of five at once.
|
||||
- Read the answer, then ask the next most valuable question given it.
|
||||
- **Stop as soon as the scope is answerable** (roughly: you could write the
|
||||
orchestrator dispatch prompt without guessing). Do not interrogate for its
|
||||
own sake.
|
||||
|
||||
## Cover, at minimum
|
||||
|
||||
1. **Scope-out**: what is explicitly NOT in scope? What might look included but isn't?
|
||||
2. **Edge cases**: empty / invalid input, network or backend error, two users at once.
|
||||
3. **Existing interactions**: does this touch behavior already in place (permissions,
|
||||
RLS, cached data, shared views, another resource)?
|
||||
4. **Definition of done**: what manual check confirms it works? Who validates before merge?
|
||||
5. **Reversibility**: if it turns out wrong, how hard is it to undo?
|
||||
|
||||
If the user answers "I don't know" to a structural question, propose an explicit
|
||||
hypothesis and ask them to confirm rather than silently choosing.
|
||||
|
||||
## After grilling
|
||||
|
||||
Fold the answers into the orchestrator dispatch prompt (scope-out and
|
||||
acceptance become ticket constraints). Then dispatch the harness as usual.
|
||||
|
||||
The orchestrator never invokes this skill: it runs headless and cannot hold an
|
||||
interactive back-and-forth. Grilling is a main-thread, pre-dispatch step.
|
||||
478
.claude/skills/playwright-testing/SKILL.md
Normal file
478
.claude/skills/playwright-testing/SKILL.md
Normal file
@@ -0,0 +1,478 @@
|
||||
---
|
||||
name: playwright-testing
|
||||
description: Playwright E2E testing patterns — web-first assertions, user-visible locators, network interception, fixtures, authentication, and parallel execution. Use when building or reviewing E2E tests with Playwright, when setting up browser testing for a web app, or when migrating from Cypress or Selenium.
|
||||
---
|
||||
|
||||
# Playwright E2E Testing
|
||||
|
||||
Reference for the assertion, locator, and structure patterns used inside an e2e spec. Browser-based end-to-end testing with auto-waiting, cross-browser support, and reliable locators.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Writing or reviewing the body of a Playwright e2e spec.
|
||||
- Setting up browser testing for the app (config, fixtures, auth, parallelism).
|
||||
- Migrating tests from Cypress or Selenium.
|
||||
|
||||
For *whether* a change requires an e2e test and *where* the spec goes, see
|
||||
`Skill({skill: "e2e-conventions"})`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Web-First Assertions (Not Manual Checks)
|
||||
|
||||
Playwright's `expect(locator)` assertions **auto-retry** until the condition is met or the timeout expires. Never use `page.$()`, `page.evaluate()`, or manual boolean checks to verify element state.
|
||||
|
||||
### WRONG — manual element checks
|
||||
|
||||
```typescript
|
||||
// Returns null immediately if element doesn't exist yet — race condition!
|
||||
const el = await page.$('.order-confirmation');
|
||||
expect(el).not.toBeNull();
|
||||
|
||||
// Evaluates once, no retry — flaky on slow renders
|
||||
const text = await page.textContent('.status');
|
||||
expect(text).toBe('Order received');
|
||||
|
||||
// Manual waitForSelector + isVisible — redundant, ignores auto-retry
|
||||
await page.waitForSelector('.status');
|
||||
const visible = await page.locator('.status').isVisible();
|
||||
expect(visible).toBe(true);
|
||||
```
|
||||
|
||||
### RIGHT — web-first assertions that auto-retry
|
||||
|
||||
```typescript
|
||||
// Auto-retries until element is visible or timeout
|
||||
await expect(page.getByText('Order received')).toBeVisible();
|
||||
|
||||
// Auto-retries until text matches
|
||||
await expect(page.getByTestId('status')).toHaveText('Order received');
|
||||
|
||||
// Auto-retries for element count
|
||||
await expect(page.getByRole('listitem')).toHaveCount(3);
|
||||
|
||||
// Auto-retries for attribute values
|
||||
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
|
||||
```
|
||||
|
||||
**Why it matters:** `page.$()` and `page.textContent()` execute once and return immediately. If the UI hasn't rendered yet, the test fails intermittently. Web-first assertions (`expect(locator).toBeVisible()`, `.toHaveText()`, `.toHaveCount()`) retry automatically until the assertion passes or the timeout expires, making tests reliable without explicit waits.
|
||||
|
||||
---
|
||||
|
||||
## 2. Locator Strategy: User-Visible Locators Over CSS
|
||||
|
||||
Prefer locators that match what the user sees. CSS selectors break when class names, IDs, or DOM structure change.
|
||||
|
||||
### WRONG — CSS/XPath selectors
|
||||
|
||||
```typescript
|
||||
page.locator('.btn-primary.submit-order');
|
||||
page.locator('#order-form > div:nth-child(3) > button');
|
||||
page.locator('//button[@class="submit"]');
|
||||
```
|
||||
|
||||
### RIGHT — user-visible locators (priority order)
|
||||
|
||||
```typescript
|
||||
// 1. BEST — role-based (matches accessible name)
|
||||
page.getByRole('button', { name: 'Place Order' });
|
||||
page.getByRole('heading', { name: /checkout/i });
|
||||
page.getByRole('link', { name: 'View cart' });
|
||||
|
||||
// 2. GOOD — label, placeholder, text
|
||||
page.getByLabel('Email address');
|
||||
page.getByPlaceholder('Search products');
|
||||
page.getByText('Order #42');
|
||||
|
||||
// 3. ACCEPTABLE — test IDs (for elements without accessible names)
|
||||
page.getByTestId('order-status-badge');
|
||||
```
|
||||
|
||||
**Why it matters:** `getByRole` tests your app the way a user (and a screen reader) interacts with it. CSS selectors like `.btn-primary` break on refactors. Test IDs are stable but don't validate accessibility — use them only as a fallback.
|
||||
|
||||
---
|
||||
|
||||
## 3. No Explicit Waits — Use Locator Assertions
|
||||
|
||||
Never use `page.waitForTimeout()`, `page.waitForSelector()`, or `setTimeout`. Playwright auto-waits on actions and web-first assertions.
|
||||
|
||||
### WRONG — explicit waits
|
||||
|
||||
```typescript
|
||||
// Arbitrary timeout — slow and still flaky
|
||||
await page.waitForTimeout(3000);
|
||||
await page.click('.submit-btn');
|
||||
|
||||
// waitForSelector is redundant before a locator action
|
||||
await page.waitForSelector('.product-card');
|
||||
await page.locator('.product-card').first().click();
|
||||
```
|
||||
|
||||
### RIGHT — rely on auto-waiting
|
||||
|
||||
```typescript
|
||||
// Playwright auto-waits for the button to be actionable before clicking
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Web-first assertion auto-retries until products appear
|
||||
await expect(page.getByRole('listitem')).toHaveCount(5);
|
||||
await page.getByRole('listitem').first().click();
|
||||
```
|
||||
|
||||
**Why it matters:** `waitForTimeout(3000)` is the #1 cause of slow test suites. Playwright already waits for elements to be visible, stable, and enabled before performing actions. Adding explicit waits is redundant and makes tests slower without adding reliability.
|
||||
|
||||
---
|
||||
|
||||
## 4. Network Interception with page.route()
|
||||
|
||||
Use `page.route()` to mock API responses for isolated, deterministic tests. Use `page.waitForResponse()` to assert on real API calls.
|
||||
|
||||
### Mocking API responses
|
||||
|
||||
```typescript
|
||||
test('shows error when API fails', async ({ page }) => {
|
||||
await page.route('**/api/products', (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Internal server error' }),
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('alert')).toContainText(/error/i);
|
||||
});
|
||||
|
||||
test('displays products from mocked API', async ({ page }) => {
|
||||
await page.route('**/api/products', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{ id: 1, name: 'Widget', price: 9.99 },
|
||||
{ id: 2, name: 'Gadget', price: 19.99 },
|
||||
]),
|
||||
})
|
||||
);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('listitem')).toHaveCount(2);
|
||||
});
|
||||
```
|
||||
|
||||
### Waiting for real API calls
|
||||
|
||||
```typescript
|
||||
test('order submission calls the API', async ({ page }) => {
|
||||
await page.goto('/checkout');
|
||||
await page.getByLabel('Name').fill('Jane Doe');
|
||||
|
||||
// Set up the response promise BEFORE triggering the action
|
||||
const responsePromise = page.waitForResponse('**/api/orders');
|
||||
await page.getByRole('button', { name: 'Place Order' }).click();
|
||||
const response = await responsePromise;
|
||||
|
||||
expect(response.status()).toBe(201);
|
||||
});
|
||||
```
|
||||
|
||||
**Why it matters:** `page.route()` intercepts at the network level, so your UI code runs exactly as in production but with controlled data. Always set up `waitForResponse` **before** the action that triggers it to avoid race conditions.
|
||||
|
||||
---
|
||||
|
||||
## 5. Test Structure: test.describe and test.beforeEach
|
||||
|
||||
### WRONG — flat tests with repeated setup
|
||||
|
||||
```typescript
|
||||
test('shows products', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: /products/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('can add to cart', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /add/i }).first().click();
|
||||
await expect(page.getByTestId('cart-count')).toHaveText('1');
|
||||
});
|
||||
```
|
||||
|
||||
### RIGHT — grouped tests with shared setup
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Product catalog', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('displays product listing', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: /products/i })).toBeVisible();
|
||||
await expect(page.getByRole('listitem')).toHaveCount(5);
|
||||
});
|
||||
|
||||
test('can add item to cart', async ({ page }) => {
|
||||
await page.getByRole('button', { name: /add/i }).first().click();
|
||||
await expect(page.getByTestId('cart-count')).toHaveText('1');
|
||||
});
|
||||
|
||||
test('can search products', async ({ page }) => {
|
||||
await page.getByPlaceholder('Search').fill('widget');
|
||||
await expect(page.getByRole('listitem')).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Why it matters:** `test.describe` groups related tests for readability and lets you share setup via `test.beforeEach`. Tests within a describe can also share hooks, annotations (like `test.slow()`), and configuration overrides.
|
||||
|
||||
---
|
||||
|
||||
## 6. Fixtures for Setup and Teardown
|
||||
|
||||
Use Playwright's fixture system to share setup logic across tests. Custom fixtures replace manual `beforeEach`/`afterEach` patterns and are composable.
|
||||
|
||||
### WRONG — manual setup in every test
|
||||
|
||||
```typescript
|
||||
test('admin can manage users', async ({ page }) => {
|
||||
// Repeated in every admin test
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill('admin@example.com');
|
||||
await page.getByLabel('Password').fill('password');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Actual test logic
|
||||
await page.goto('/admin/users');
|
||||
await expect(page.getByRole('table')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### RIGHT — custom fixture
|
||||
|
||||
```typescript
|
||||
// e2e/fixtures.ts
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
|
||||
type Fixtures = {
|
||||
adminPage: import('@playwright/test').Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<Fixtures>({
|
||||
adminPage: async ({ page }, use) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill('admin@example.com');
|
||||
await page.getByLabel('Password').fill('password');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await page.waitForURL('/dashboard');
|
||||
await use(page);
|
||||
// Teardown runs automatically after test
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
|
||||
// e2e/admin.spec.ts
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('admin can manage users', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/users');
|
||||
await expect(adminPage.getByRole('table')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Authentication with storageState
|
||||
|
||||
For apps requiring login, authenticate once in a setup project and reuse the session via `storageState`. This avoids logging in before every test.
|
||||
|
||||
### playwright.config.ts — setup project
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||
{
|
||||
name: 'chromium',
|
||||
dependencies: ['setup'],
|
||||
use: { storageState: 'e2e/.auth/user.json' },
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### e2e/auth.setup.ts — run once
|
||||
|
||||
```typescript
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
|
||||
const authFile = 'e2e/.auth/user.json';
|
||||
|
||||
setup('authenticate', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Email').fill('user@example.com');
|
||||
await page.getByLabel('Password').fill('password');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await page.waitForURL('/dashboard');
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
```
|
||||
|
||||
**Why it matters:** Without `storageState`, every test file repeats the login flow, which is slow and fragile. The setup project runs once, saves cookies/localStorage to a JSON file, and all dependent tests start already authenticated.
|
||||
|
||||
---
|
||||
|
||||
## 8. Page Object Model
|
||||
|
||||
Encapsulate page interactions in classes to reduce duplication and make tests resilient to UI changes.
|
||||
|
||||
```typescript
|
||||
// e2e/pages/checkout.page.ts
|
||||
import { type Page, type Locator, expect } from '@playwright/test';
|
||||
|
||||
export class CheckoutPage {
|
||||
readonly nameInput: Locator;
|
||||
readonly emailInput: Locator;
|
||||
readonly submitButton: Locator;
|
||||
readonly confirmationMessage: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.nameInput = page.getByLabel('Full name');
|
||||
this.emailInput = page.getByLabel('Email');
|
||||
this.submitButton = page.getByRole('button', { name: 'Place Order' });
|
||||
this.confirmationMessage = page.getByText(/order.*confirmed/i);
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/checkout');
|
||||
}
|
||||
|
||||
async fillAndSubmit(name: string, email: string) {
|
||||
await this.nameInput.fill(name);
|
||||
await this.emailInput.fill(email);
|
||||
await this.submitButton.click();
|
||||
}
|
||||
|
||||
async expectConfirmation() {
|
||||
await expect(this.confirmationMessage).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
// e2e/checkout.spec.ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { CheckoutPage } from './pages/checkout.page';
|
||||
|
||||
test('completes checkout', async ({ page }) => {
|
||||
const checkout = new CheckoutPage(page);
|
||||
await checkout.goto();
|
||||
await checkout.fillAndSubmit('Jane Doe', 'jane@example.com');
|
||||
await checkout.expectConfirmation();
|
||||
});
|
||||
```
|
||||
|
||||
**Why it matters:** When the UI changes (button text, label wording), you update one page object instead of every test file. Page objects also keep test bodies focused on the user scenario, not DOM details.
|
||||
|
||||
---
|
||||
|
||||
## 9. Configuration
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
screenshot: 'only-on-failure',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
port: 3000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Key config settings
|
||||
|
||||
- **`fullyParallel: true`** — runs tests in parallel by default (Playwright's default). Do not disable unless tests share mutable state.
|
||||
- **`forbidOnly: !!process.env.CI`** — fails CI if `test.only` is left in code.
|
||||
- **`webServer`** — auto-starts your dev server before tests.
|
||||
- **`trace: 'on-first-retry'`** — captures traces on first retry for debugging via `npx playwright show-trace`.
|
||||
- **`screenshot: 'only-on-failure'`** — captures screenshots only when tests fail.
|
||||
|
||||
---
|
||||
|
||||
## 10. Visual Regression Testing
|
||||
|
||||
```typescript
|
||||
test('homepage matches snapshot', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveScreenshot('homepage.png');
|
||||
});
|
||||
|
||||
test('product card renders correctly', async ({ page }) => {
|
||||
await page.goto('/products');
|
||||
await expect(page.getByTestId('product-card').first()).toHaveScreenshot('product-card.png', {
|
||||
maxDiffPixels: 100,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Run `npx playwright test --update-snapshots` to generate baseline images. Snapshots are stored per-project (browser) and per-platform.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Playwright Docs: Locators](https://playwright.dev/docs/locators)
|
||||
- [Playwright Docs: Assertions](https://playwright.dev/docs/test-assertions)
|
||||
- [Playwright Docs: Auto-waiting](https://playwright.dev/docs/actionability)
|
||||
- [Playwright Docs: Network](https://playwright.dev/docs/network)
|
||||
- [Playwright Docs: Authentication](https://playwright.dev/docs/auth)
|
||||
- [Playwright Docs: Fixtures](https://playwright.dev/docs/test-fixtures)
|
||||
- [Playwright Docs: Page Object Model](https://playwright.dev/docs/pom)
|
||||
|
||||
---
|
||||
|
||||
## Red Flags
|
||||
|
||||
- `page.$()` / `page.textContent()` / `isVisible()` checks instead of web-first assertions.
|
||||
- CSS or XPath selectors where a `getByRole`/`getByLabel`/`getByText` locator exists.
|
||||
- Any `waitForTimeout()`, or `waitForSelector()` before a locator action.
|
||||
- `waitForResponse()` set up *after* the action that triggers the call.
|
||||
- Login flow repeated per test instead of `storageState` or a fixture.
|
||||
- `test.only` left in code, or `fullyParallel` disabled without a shared-state reason.
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] Web-first assertions (`expect(locator).toBeVisible()`) — never `page.$()` or `page.textContent()`
|
||||
- [ ] User-visible locators (`getByRole`, `getByLabel`, `getByText`) over CSS selectors
|
||||
- [ ] No explicit waits (`waitForTimeout`, `waitForSelector` before actions)
|
||||
- [ ] `test.describe` groups related tests; `test.beforeEach` for shared setup
|
||||
- [ ] `page.route()` for API mocking in isolated tests
|
||||
- [ ] `page.waitForResponse()` set up before the triggering action
|
||||
- [ ] `webServer` configured to start app automatically
|
||||
- [ ] `fullyParallel: true` for parallel execution
|
||||
- [ ] `forbidOnly: !!process.env.CI` to prevent `test.only` in CI
|
||||
- [ ] Screenshots on failure (`screenshot: 'only-on-failure'`)
|
||||
- [ ] Traces on retry (`trace: 'on-first-retry'`)
|
||||
- [ ] Retries in CI (`retries: process.env.CI ? 2 : 0`)
|
||||
- [ ] `storageState` for authentication reuse across tests
|
||||
- [ ] Page objects encapsulate locators and actions for reusable pages
|
||||
40
.claude/skills/ponytail-audit/SKILL.md
Normal file
40
.claude/skills/ponytail-audit/SKILL.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: ponytail-audit
|
||||
description: >
|
||||
Whole-repo audit for over-engineering. Like ponytail-review, but scans the
|
||||
entire codebase instead of a diff: a ranked list of what to delete, simplify,
|
||||
or replace with stdlib/native equivalents. Use when the user says "audit this
|
||||
codebase", "audit for over-engineering", "what can I delete from this repo",
|
||||
"find bloat", "ponytail-audit", or "/ponytail-audit". One-shot report, does
|
||||
not apply fixes.
|
||||
---
|
||||
|
||||
ponytail-review, repo-wide. Scan the whole tree instead of a diff. Rank
|
||||
findings biggest cut first.
|
||||
|
||||
## Tags
|
||||
|
||||
Same as ponytail-review:
|
||||
|
||||
- `delete:` dead code, unused flexibility, speculative feature. Replacement: nothing.
|
||||
- `stdlib:` hand-rolled thing the standard library ships. Name the function.
|
||||
- `native:` dependency or code doing what the platform already does. Name the feature.
|
||||
- `yagni:` abstraction with one implementation, config nobody sets, layer with one caller.
|
||||
- `shrink:` same logic, fewer lines. Show the shorter form.
|
||||
|
||||
## Hunt
|
||||
|
||||
Deps the stdlib or platform already ships, single-implementation interfaces,
|
||||
factories with one product, wrappers that only delegate, files exporting one
|
||||
thing, dead flags and config, hand-rolled stdlib.
|
||||
|
||||
## Output
|
||||
|
||||
One line per finding, ranked: `<tag> <what to cut>. <replacement>. [path]`.
|
||||
End with `net: -<N> lines, -<M> deps possible.` Nothing to cut: `Lean already. Ship.`
|
||||
|
||||
## Boundaries
|
||||
|
||||
Complexity only, correctness bugs, security holes, and performance go to a
|
||||
normal review pass. Lists findings, applies nothing. One-shot.
|
||||
"stop ponytail-audit" or "normal mode" to revert.
|
||||
44
.claude/skills/ponytail-debt/SKILL.md
Normal file
44
.claude/skills/ponytail-debt/SKILL.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: ponytail-debt
|
||||
description: >
|
||||
Harvest every `ponytail:` comment in the codebase into a debt ledger, so the
|
||||
deliberate shortcuts and deferrals ponytail leaves behind get tracked instead
|
||||
of rotting into "later means never". Use when the user says "ponytail debt",
|
||||
"/ponytail-debt", "what did ponytail defer", "list the shortcuts", "ponytail
|
||||
ledger", or "what did we mark to do later". One-shot report, changes nothing.
|
||||
---
|
||||
|
||||
Every deliberate ponytail shortcut is marked with a `ponytail:` comment naming
|
||||
its ceiling and upgrade path. This collects them into one ledger so a deferral
|
||||
can't quietly become permanent.
|
||||
|
||||
## Scan
|
||||
|
||||
Grep the repo for comment markers, skipping `node_modules`, `.git`, and build
|
||||
output:
|
||||
|
||||
`grep -rnE '(#|//) ?ponytail:' .` (add other comment prefixes if your stack uses them)
|
||||
|
||||
Each hit is one ledger row. The comment prefix keeps prose that merely mentions
|
||||
the convention out of the ledger.
|
||||
|
||||
## Output
|
||||
|
||||
One row per marker, grouped by file:
|
||||
|
||||
`<file>:<line> — <what was simplified>. ceiling: <the limit named>. upgrade: <the trigger to revisit>.`
|
||||
|
||||
The convention is `ponytail: <ceiling>, <upgrade path>`, so pull the ceiling
|
||||
and the trigger straight from the comment. Want an owner per row too? add
|
||||
`git blame -L<line>,<line>`.
|
||||
|
||||
Flag the rot risk: any `ponytail:` comment that names no upgrade path or
|
||||
trigger gets a `no-trigger` tag, those are the ones that silently rot.
|
||||
|
||||
End with `<N> markers, <M> with no trigger.` Nothing found: `No ponytail: debt. Clean ledger.`
|
||||
|
||||
## Boundaries
|
||||
|
||||
Reads and reports only, changes nothing. To persist it, ask and it writes the
|
||||
ledger to a file (e.g. `PONYTAIL-DEBT.md`). One-shot. "stop ponytail-debt" or
|
||||
"normal mode" to revert.
|
||||
68
.claude/skills/ponytail-help/SKILL.md
Normal file
68
.claude/skills/ponytail-help/SKILL.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
name: ponytail-help
|
||||
description: >
|
||||
Quick-reference card for all ponytail modes, skills, and commands.
|
||||
One-shot display, not a persistent mode. Trigger: /ponytail-help,
|
||||
"ponytail help", "what ponytail commands", "how do I use ponytail".
|
||||
---
|
||||
|
||||
# Ponytail Help
|
||||
|
||||
Display this reference card when invoked. One-shot, do NOT change mode,
|
||||
write flag files, or persist anything.
|
||||
|
||||
## Levels
|
||||
|
||||
| Level | Trigger | What change |
|
||||
|-------|---------|-------------|
|
||||
| **Lite** | `/ponytail lite` | Build what's asked, name the lazier alternative in one line. |
|
||||
| **Full** | `/ponytail` | The ladder enforced: YAGNI → stdlib → native → one line → minimum. Default. |
|
||||
| **Ultra** | `/ponytail ultra` | YAGNI extremist. Deletion before addition. Challenges requirements before building. |
|
||||
|
||||
Level sticks until changed or session end.
|
||||
|
||||
## Skills
|
||||
|
||||
| Skill | Trigger | What it does |
|
||||
|-------|---------|--------------|
|
||||
| **ponytail** | `/ponytail` | Lazy mode itself. Simplest solution that works. |
|
||||
| **ponytail-review** | `/ponytail-review` | Over-engineering review: `L42: yagni: factory, one product. Inline.` |
|
||||
| **ponytail-help** | `/ponytail-help` | This card. |
|
||||
|
||||
Codex uses `@ponytail`, `@ponytail-review`, and `@ponytail-help`; Claude Code
|
||||
and OpenCode use the slash-command forms above (OpenCode ships `/ponytail` and
|
||||
`/ponytail-review`).
|
||||
|
||||
## Deactivate
|
||||
|
||||
Say "stop ponytail" or "normal mode". Resume anytime with `/ponytail`.
|
||||
`/ponytail off` also works.
|
||||
|
||||
## Configure Default Mode
|
||||
|
||||
Default mode = `full`, auto-active every session. Change it:
|
||||
|
||||
**Environment variable** (highest priority):
|
||||
```bash
|
||||
export PONYTAIL_DEFAULT_MODE=ultra
|
||||
```
|
||||
|
||||
**Config file** (`~/.config/ponytail/config.json`, Windows: `%APPDATA%\ponytail\config.json`):
|
||||
```json
|
||||
{ "defaultMode": "lite" }
|
||||
```
|
||||
|
||||
Set `"off"` to disable auto-activation on session start, activate manually
|
||||
with `/ponytail` when wanted.
|
||||
|
||||
Resolution: env var > config file > `full`.
|
||||
|
||||
## Update
|
||||
|
||||
Enable auto-update once: open `/plugin`, go to Marketplaces, pick ponytail, Enable auto-update. Claude Code then pulls new versions at startup (run `/reload-plugins` when it prompts). Manual refresh: `/plugin marketplace update ponytail` then `/reload-plugins`.
|
||||
|
||||
If `/plugin` is not recognized, your Claude Code is out of date. Update it (`npm install -g @anthropic-ai/claude-code@latest`, or `brew upgrade claude-code`) and restart. Other hosts use their own update flow.
|
||||
|
||||
## More
|
||||
|
||||
Full docs + examples: https://github.com/DietrichGebert/ponytail
|
||||
56
.claude/skills/ponytail-review/SKILL.md
Normal file
56
.claude/skills/ponytail-review/SKILL.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: ponytail-review
|
||||
description: >
|
||||
Code review focused exclusively on over-engineering. Finds what to delete:
|
||||
reinvented standard library, unneeded dependencies, speculative abstractions,
|
||||
dead flexibility. One line per finding: location, what to cut, what replaces
|
||||
it. Use when the user says "review for over-engineering", "what can we
|
||||
delete", "is this over-engineered", "simplify review", or invokes
|
||||
/ponytail-review. Complements correctness-focused review, this one only
|
||||
hunts complexity.
|
||||
---
|
||||
|
||||
Review diffs for unnecessary complexity. One line per finding: location, what
|
||||
to cut, what replaces it. The diff's best outcome is getting shorter.
|
||||
|
||||
## Format
|
||||
|
||||
`L<line>: <tag> <what>. <replacement>.`, or `<file>:L<line>: ...` for
|
||||
multi-file diffs.
|
||||
|
||||
Tags:
|
||||
|
||||
- `delete:` dead code, unused flexibility, speculative feature. Replacement: nothing.
|
||||
- `stdlib:` hand-rolled thing the standard library ships. Name the function.
|
||||
- `native:` dependency or code doing what the platform already does. Name the feature.
|
||||
- `yagni:` abstraction with one implementation, config nobody sets, layer with one caller.
|
||||
- `shrink:` same logic, fewer lines. Show the shorter form.
|
||||
|
||||
## Examples
|
||||
|
||||
❌ "This EmailValidator class might be more complex than necessary, have you
|
||||
considered whether all these validation rules are needed at this stage?"
|
||||
|
||||
✅ `L12-38: stdlib: 27-line validator class. "@" in email, 1 line, real validation is the confirmation mail.`
|
||||
|
||||
✅ `L4: native: moment.js imported for one format call. Intl.DateTimeFormat, 0 deps.`
|
||||
|
||||
✅ `repo.py:L88: yagni: AbstractRepository with one implementation. Inline it until a second one exists.`
|
||||
|
||||
✅ `L52-71: delete: retry wrapper around an idempotent local call. Nothing replaces it.`
|
||||
|
||||
✅ `L30-44: shrink: manual loop builds dict. dict(zip(keys, values)), 1 line.`
|
||||
|
||||
## Scoring
|
||||
|
||||
End with the only metric that matters: `net: -<N> lines possible.`
|
||||
|
||||
If there is nothing to cut, say `Lean already. Ship.` and stop.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Complexity only, correctness bugs, security holes, and performance go to a
|
||||
normal review pass, not this one. A single smoke test or `assert`-based
|
||||
self-check is the ponytail minimum, not bloat, never flag it for deletion.
|
||||
Does not apply the fixes, only lists them.
|
||||
"stop ponytail-review" or "normal mode": revert to verbose review style.
|
||||
100
.claude/skills/ponytail/SKILL.md
Normal file
100
.claude/skills/ponytail/SKILL.md
Normal file
@@ -0,0 +1,100 @@
|
||||
---
|
||||
name: ponytail
|
||||
description: >
|
||||
Forces the laziest solution that actually works, simplest, shortest, most
|
||||
minimal. Channels a senior dev who has seen everything: question whether the
|
||||
task needs to exist at all (YAGNI), reach for the standard library before
|
||||
custom code, native platform features before dependencies, one line before
|
||||
fifty. Supports intensity levels: lite, full (default), ultra. Use whenever
|
||||
the user says "ponytail", "be lazy", "lazy mode", "simplest solution",
|
||||
"minimal solution", "yagni", "do less", or "shortest path", and whenever
|
||||
they complain about over-engineering, bloat, boilerplate, or unnecessary
|
||||
dependencies.
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# Ponytail
|
||||
|
||||
You are a lazy senior developer. Lazy means efficient, not careless. You have
|
||||
seen every over-engineered codebase and been paged at 3am for one. The best
|
||||
code is the code never written.
|
||||
|
||||
## Persistence
|
||||
|
||||
ACTIVE EVERY RESPONSE. No drift back to over-building. Still active if
|
||||
unsure. Off only: "stop ponytail" / "normal mode". Default: **full**.
|
||||
Switch: `/ponytail lite|full|ultra`.
|
||||
|
||||
## The ladder
|
||||
|
||||
Stop at the first rung that holds:
|
||||
|
||||
1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI)
|
||||
2. **Stdlib does it?** Use it.
|
||||
3. **Native platform feature covers it?** `<input type="date">` over a picker lib, CSS over JS, DB constraint over app code.
|
||||
4. **Already-installed dependency solves it?** Use it. Never add a new one for what a few lines can do.
|
||||
5. **Can it be one line?** One line.
|
||||
6. **Only then:** the minimum code that works.
|
||||
|
||||
The ladder is a reflex, not a research project. Two rungs work → take the
|
||||
higher one and move on. The first lazy solution that works is the right one.
|
||||
|
||||
## Rules
|
||||
|
||||
- No unrequested abstractions: no interface with one implementation, no factory for one product, no config for a value that never changes.
|
||||
- No boilerplate, no scaffolding "for later", later can scaffold for itself.
|
||||
- Deletion over addition. Boring over clever, clever is what someone decodes at 3am.
|
||||
- Fewest files possible. Shortest working diff wins.
|
||||
- Complex request? Ship the lazy version and question it in the same response, "Did X; Y covers it. Need full X? Say so." Never stall on an answer you can default.
|
||||
- Two stdlib options, same size? Take the one that's correct on edge cases. Lazy means writing less code, not picking the flimsier algorithm.
|
||||
- Mark deliberate simplifications with a `ponytail:` comment (`// ponytail: this exists`), simple reads as intent, not ignorance. Shortcut with a known ceiling (global lock, O(n²) scan, naive heuristic)? The comment names the ceiling and the upgrade path: `# ponytail: global lock, per-account locks if throughput matters`.
|
||||
|
||||
## Output
|
||||
|
||||
Code first. Then at most three short lines: what was skipped, when to add it.
|
||||
No essays, no feature tours, no design notes. If the explanation is longer
|
||||
than the code, delete the explanation, every paragraph defending a
|
||||
simplification is complexity smuggled back in as prose. Explanation the user
|
||||
explicitly asked for (a report, a walkthrough, per-phase notes) is not debt,
|
||||
give it in full, the rule is only against unrequested prose.
|
||||
|
||||
Pattern: `[code] → skipped: [X], add when [Y].`
|
||||
|
||||
## Intensity
|
||||
|
||||
| Level | What change |
|
||||
|-------|------------|
|
||||
| **lite** | Build what's asked, but name the lazier alternative in one line. User picks. |
|
||||
| **full** | The ladder enforced. Stdlib and native first. Shortest diff, shortest explanation. Default. |
|
||||
| **ultra** | YAGNI extremist. Deletion before addition. Ship the one-liner and challenge the rest of the requirement in the same breath. |
|
||||
|
||||
Example: "Add a cache for these API responses."
|
||||
- lite: "Done, cache added. FYI: `functools.lru_cache` covers this in one line if you'd rather not own a cache class."
|
||||
- full: "`@lru_cache(maxsize=1000)` on the fetch function. Skipped custom cache class, add when lru_cache measurably falls short."
|
||||
- ultra: "No cache until a profiler says so. When it does: `@lru_cache`. A hand-rolled TTL cache class is a bug farm with a hit rate."
|
||||
|
||||
## When NOT to be lazy
|
||||
|
||||
Never simplify away: input validation at trust boundaries, error handling
|
||||
that prevents data loss, security measures, accessibility basics, anything
|
||||
explicitly requested. User insists on the full version → build it, no
|
||||
re-arguing.
|
||||
|
||||
Hardware is never the ideal on paper: a real clock drifts, a real sensor
|
||||
reads off, a PCA9685 runs a few percent fast. Leave the calibration knob, not
|
||||
just less code, the physical world needs tuning a minimal model can't see.
|
||||
|
||||
Lazy code without its check is unfinished. Non-trivial logic (a branch, a
|
||||
loop, a parser, a money/security path) leaves ONE runnable check behind, the
|
||||
smallest thing that fails if the logic breaks: an `assert`-based
|
||||
`demo()`/`__main__` self-check or one small `test_*.py`. No frameworks, no
|
||||
fixtures, no per-function suites unless asked. Trivial one-liners need no
|
||||
test, YAGNI applies to tests too.
|
||||
|
||||
## Boundaries
|
||||
|
||||
Ponytail governs what you build, not how you talk (pair with Caveman for
|
||||
terse prose). "stop ponytail" / "normal mode": revert. Level persists until
|
||||
changed or session end.
|
||||
|
||||
The shortest path to done is the right path.
|
||||
158
.claude/skills/resolving-rollback-conflicts/SKILL.md
Normal file
158
.claude/skills/resolving-rollback-conflicts/SKILL.md
Normal file
@@ -0,0 +1,158 @@
|
||||
---
|
||||
name: resolving-rollback-conflicts
|
||||
description: Replay a list of git-revert commits against the current base branch and resolve the conflicts, when the chat-service's automatic rollback hit a merge conflict it couldn't resolve. Load this when your developer dispatch asks you to resolve a rollback conflict (it carries BASE_BRANCH, FAILED_COMMIT, COMMITS_TO_REVERT). This is NOT a feature ticket — the workflow below replaces the normal ticket rules.
|
||||
---
|
||||
|
||||
# Resolving rollback conflicts
|
||||
|
||||
The chat-service's HTTP `/rollback` route attempted to `git revert -m 1 <sha>` each merge commit on the base branch, and one of them conflicted. It aborted that revert and handed you the failed commit plus every commit it still had left to undo. Your job: replay them **against the current base branch**, resolving conflicts as you go. You are dispatched **alone** — no team, no peers. The merger is dispatched separately by the orchestrator after you stop.
|
||||
|
||||
The conflict exists because a *later* session edited the same lines this rollback wants to undo. That conflict only shows up against the current base branch — not against the stale `session/<SESSION_SHORT_ID>` your worktree was forked from. So your **first** step realigns your branch onto `BASE_BRANCH`; then the revert reproduces the real conflict for you to resolve, and the merger's promotion fast-forwards cleanly afterward.
|
||||
|
||||
## Spawn prompt — what you receive
|
||||
|
||||
```
|
||||
ROLE: developer
|
||||
WORKTREE_PATH: <WORKTREE_BASE>/simple
|
||||
BRANCH_NAME: <SESSION_SHORT_ID>/simple
|
||||
BASE_BRANCH: <the default branch, e.g. master — the promotion target>
|
||||
FAILED_COMMIT: <short sha> ("<subject>")
|
||||
COMMITS_TO_REVERT:
|
||||
- <sha> # <subject>
|
||||
- ...
|
||||
```
|
||||
|
||||
**Working directory is `<WORKTREE_PATH>`** — the shared `<base>/simple` worktree the `setup-worktree` hook creates for you. Every Bash call must `cd <WORKTREE_PATH> && …` (shell state is stateless between calls). Do NOT touch `$CLAUDE_PROJECT_DIR/src/...` — that's the base branch.
|
||||
|
||||
## Output contract
|
||||
|
||||
Your very last line of output MUST be exactly one of:
|
||||
|
||||
- `DONE: branch=<SESSION_SHORT_ID>/simple files=[<every file you touched, deduped>]`
|
||||
- `FAILED: <one-line, plain-English reason — say what was confusing>`
|
||||
|
||||
The orchestrator's STATE RB-MERGE relies on those literal prefixes.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 0 — Realign your branch onto the base branch (do this ONCE, first)
|
||||
|
||||
Your worktree was forked from the stale `session/<SESSION_SHORT_ID>`, where the conflicting later changes don't exist — revert there and it applies cleanly but the conflict resurfaces, unresolved, at promotion. Reset your branch onto the current base branch so you replay the reverts against the exact state the HTTP route hit the conflict on:
|
||||
|
||||
```bash
|
||||
cd <WORKTREE_PATH> && git reset --hard <BASE_BRANCH>
|
||||
```
|
||||
|
||||
This is the only branch-moving command you may run, and only here. After it, proceed to the loop below.
|
||||
|
||||
Then, for each SHA in `COMMITS_TO_REVERT`, in the order given:
|
||||
|
||||
### Step 1 — Read the commit you're about to undo
|
||||
|
||||
Before touching anything, get the canonical record of what this commit changed. This is the **ground truth** you'll use to resolve conflicts and to interpret empty reverts:
|
||||
|
||||
```bash
|
||||
cd <WORKTREE_PATH> && git show --stat <sha> # which files, how big
|
||||
cd <WORKTREE_PATH> && git show <sha> # full diff
|
||||
```
|
||||
|
||||
Read the diff. Identify, in your head, **exactly** what this commit added (the `+` lines on the "after" side) and what it replaced (the `-` lines). The revert's job is to remove the `+` and put back the `-` — nothing else.
|
||||
|
||||
### Step 2 — Attempt the revert
|
||||
|
||||
```bash
|
||||
cd <WORKTREE_PATH> && git revert --no-edit -m 1 <sha>
|
||||
```
|
||||
|
||||
Three possible outcomes — match yours below.
|
||||
|
||||
### Outcome A — Clean revert with real changes
|
||||
|
||||
`git revert` exited 0 AND `git diff --name-only HEAD^ HEAD` is non-empty. Sanity-check: do the changed files / changed lines match what Step 1 told you to expect? If yes, go to next SHA.
|
||||
|
||||
### Outcome B — Empty revert (clean exit, zero changes)
|
||||
|
||||
`git revert` exited 0 BUT `git diff --name-only HEAD^ HEAD` is empty.
|
||||
|
||||
This means a later commit on the base branch has **already removed or transformed** the lines your target commit added. Two sub-cases:
|
||||
|
||||
- **B1 — Pure substitution** (e.g. target said `X → Y`, current main says `Z`): the user's intent — "undo X → Y" — can sometimes still be expressed as `Z → X`. Look at Step 1's `+` strings; grep current main for them; if they're absent but the file at the same location now has a different value, that's the later commit's overwrite. Edit the file to replace that later value with the original `-` strings from Step 1. Then commit **with the revert marker** so a future rollback knows this commit was already undone:
|
||||
```bash
|
||||
cd <WORKTREE_PATH> && git add -A && git commit -m "revert: semantic revert of <sha-short>" -m "This reverts commit <FULL 40-char sha>."
|
||||
```
|
||||
The second `-m` line is mandatory: the chat-service detects already-reverted commits by scanning for `This reverts commit <sha>`. Native `git revert` / `git revert --continue` write this marker automatically; a manual semantic revert must add it explicitly or the commit will be re-reverted on the next rollback. Re-check with `git diff --name-only HEAD^ HEAD` — it should be non-empty now.
|
||||
- **B2 — Target already fully absent** (the later commit removed the addition entirely, no equivalent value to swap): the target commit's effect is already gone from main. Nothing to revert.
|
||||
```bash
|
||||
cd <WORKTREE_PATH> && git reset --hard HEAD^
|
||||
```
|
||||
```
|
||||
FAILED: revert of <sha> produced no changes — its additions have already been removed by a later commit
|
||||
```
|
||||
|
||||
If you're unsure which sub-case applies, prefer B2 (FAILED) — a confusing-but-honest failure is better than a hallucinated edit that touches files the user didn't expect.
|
||||
|
||||
### Outcome C — Conflict (non-zero exit, unmerged paths)
|
||||
|
||||
`git status` shows `You are currently reverting commit <sha>` plus `UU`/`AA`/`DU`/`UD` entries.
|
||||
|
||||
```bash
|
||||
cd <WORKTREE_PATH> && git status --porcelain
|
||||
cd <WORKTREE_PATH> && git diff --diff-filter=U
|
||||
```
|
||||
|
||||
For each conflict file, the markers look like:
|
||||
|
||||
```
|
||||
<<<<<<< HEAD
|
||||
<current state on the base branch — includes whatever later commits added>
|
||||
=======
|
||||
<state after applying the revert — the target commit's additions are removed,
|
||||
but later commits' additions on the SAME lines might also be missing here>
|
||||
>>>>>>> parent of <sha>...
|
||||
```
|
||||
|
||||
**Goal**: produce a version where the target commit's `+` lines (from Step 1) are removed, but **every** later commit's contribution that you can identify is preserved.
|
||||
|
||||
Heuristic, in order:
|
||||
|
||||
1. **Read both sides side-by-side**. Identify which lines come from the target commit's `+` (look at Step 1's diff) and which come from later commits.
|
||||
2. **Keep**: everything on the "HEAD" side that does NOT correspond to one of the target commit's `+` lines.
|
||||
3. **Drop**: the target commit's `+` lines (and only those).
|
||||
4. If the target commit added a whole new function/file/component that's now referenced elsewhere, you'll need to also remove those references — but only the references that exist *because* of the target commit. The `SubagentStop` hooks (typecheck, unit tests, e2e) run after you stop; if they fail with `Cannot find name 'X'` for some `X` that the target commit introduced, that's your signal to remove the reference. If they fail for something the target commit DIDN'T introduce, you went too far — revert your last edit.
|
||||
|
||||
After resolving every conflict file:
|
||||
|
||||
```bash
|
||||
cd <WORKTREE_PATH> && git add -A && git revert --continue --no-edit
|
||||
```
|
||||
|
||||
Then re-check `git diff --name-only HEAD^ HEAD`. If empty, apply the Outcome B logic. Otherwise, go to next SHA.
|
||||
|
||||
### Step 3 — All SHAs processed
|
||||
|
||||
Emit the output contract:
|
||||
|
||||
```
|
||||
DONE: branch=<SESSION_SHORT_ID>/simple files=[<every file you touched, deduped>]
|
||||
```
|
||||
|
||||
The orchestrator's STATE RB-MERGE will dispatch the single-shot merger to merge your branch back into the base.
|
||||
|
||||
### Step 4 — Unrecoverable failure
|
||||
|
||||
If at any point you can't make progress (a conflict you genuinely can't read, three rounds of validation hook failures with no fix in sight, etc.):
|
||||
|
||||
```bash
|
||||
cd <WORKTREE_PATH> && git revert --abort 2>/dev/null || true
|
||||
```
|
||||
```
|
||||
FAILED: rollback merge failed: <one-line, plain-English reason — say what was confusing>
|
||||
```
|
||||
|
||||
## NEVER while resolving a rollback conflict
|
||||
|
||||
- ❌ Run `git merge`, `git push`, `git checkout`, `--no-verify`. The only branch-moving commands allowed are: `git reset --hard <BASE_BRANCH>` exactly once at Step 0, and `git reset --hard HEAD^` to undo your own empty revert as documented in Outcome B.
|
||||
- ❌ Edit anything outside `<WORKTREE_PATH>/`. Never edit `$CLAUDE_PROJECT_DIR/...` directly, never edit `.git/` internals.
|
||||
- ❌ Drive-by refactors, prettier formatting changes, or unrelated edits. Edits must be **caused** by the revert (target additions to remove) or by a typecheck/unit/e2e failure that the revert created.
|
||||
- ❌ Dispatch agents or `SendMessage` — you are solo here.
|
||||
- ❌ Stop without either `DONE:` or `FAILED:` — the orchestrator's STATE RB-MERGE relies on those literal prefixes.
|
||||
300
.claude/skills/setup-interview/SKILL.md
Normal file
300
.claude/skills/setup-interview/SKILL.md
Normal file
@@ -0,0 +1,300 @@
|
||||
---
|
||||
name: setup-interview
|
||||
description: Domain-by-domain interview to produce $CLAUDE_PROJECT_DIR/docs/project-context.json. Invoked once by the orchestrator; the orchestrator then conducts all turns directly using Read/Write/Edit — no agent dispatching.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A domain-by-domain interview that produces `$CLAUDE_PROJECT_DIR/docs/project-context.json`. You drive it one user turn at a time, persisting after each domain, until the user confirms — exit criterion: a `validated: true` JSON committed and the `VALIDATED` token emitted.
|
||||
|
||||
**Scope constraint**: during SETUP-INTERVIEW, your Write / Edit tools are
|
||||
restricted to `$CLAUDE_PROJECT_DIR/docs/project-context.json` only. Never touch `$CLAUDE_PROJECT_DIR/src/` or
|
||||
any other path.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Invoked once by the orchestrator at the start of project setup; the orchestrator then conducts every turn directly.
|
||||
- Resuming an interrupted interview (`validated: false`) or updating an existing config (`validated: true`).
|
||||
|
||||
---
|
||||
|
||||
## FIRST ACTION — startup detection
|
||||
|
||||
`Read("$CLAUDE_PROJECT_DIR/docs/project-context.json")`:
|
||||
|
||||
| State | Action |
|
||||
|---|---|
|
||||
| File missing | Start a FRESH interview from domain 1. |
|
||||
| File exists, `validated: true` | Summarize existing config (template below) and ask: (a) update specific domains, or (b) restart from scratch. |
|
||||
| File exists, `validated: false` | Resume from the last `pending` entry in `interview_progress`. |
|
||||
|
||||
### Existing-config summary template (validated == true)
|
||||
|
||||
Output a plain-text message to the user (translate into the user's language at runtime):
|
||||
|
||||
```
|
||||
Here's what I already know about your project:
|
||||
- Industry: <industry>
|
||||
- Team size: <team_size>
|
||||
- Client type: <client_type>
|
||||
- Main objective: <objective>
|
||||
- <N> entities defined: <comma-separated names>
|
||||
- Pipeline: <pipeline_stages joined>
|
||||
- <N> user roles
|
||||
|
||||
Would you like to:
|
||||
(a) update part of this config (I'll go domain by domain),
|
||||
(b) start from scratch (I'll wipe everything and redo the full interview)?
|
||||
```
|
||||
|
||||
- Update intent → **UPDATE flow**: for each domain, ask "Current value: `<…>`. Keep as-is, or change it?"
|
||||
- Restart intent → delete the file, start FRESH.
|
||||
|
||||
---
|
||||
|
||||
## Per-turn loop (FRESH and UPDATE flows)
|
||||
|
||||
Each user turn during SETUP-INTERVIEW:
|
||||
|
||||
1. `Read("$CLAUDE_PROJECT_DIR/docs/project-context.json")` — load current state.
|
||||
2. Apply the user's latest answer to the relevant domain section.
|
||||
3. Update `interview_progress.domain_N` to `"done"`, keep `validated: false`.
|
||||
4. `Write("$CLAUDE_PROJECT_DIR/docs/project-context.json", <full updated JSON>)`.
|
||||
5. Move to the next pending domain.
|
||||
6. Output **exactly one** of:
|
||||
- The next question as a **plain text message** to the user (no prefix, no wrapper).
|
||||
- `VALIDATED` — when final confirmation is received (see Final validation).
|
||||
- `FAILED: <reason>` — unrecoverable error.
|
||||
|
||||
Summarize what you understood after each domain and wait for confirmation before
|
||||
moving to the next. Never ask multiple domains in one turn.
|
||||
|
||||
---
|
||||
|
||||
## Domain questions
|
||||
|
||||
### Domain 1 — Business context
|
||||
- Industry, team size, client type (B2B / B2C / mixed), main objective
|
||||
(prospecting, follow-up, support, other).
|
||||
|
||||
### Domain 2 — Entities
|
||||
- What objects are managed? Relationships between them?
|
||||
- ⚠️ If an entity resembles `contact`, `company`, `deal`, `tag`, `task`, or
|
||||
`note` (already in Atomic CRM) → propose extending it (`"type": "extend"`)
|
||||
rather than recreating it.
|
||||
|
||||
### Domain 3 — Custom fields
|
||||
- Specific fields per entity beyond standard fields.
|
||||
- Type of each field (text / number / date / boolean / list / file).
|
||||
- Required vs optional.
|
||||
|
||||
### Domain 4 — Pipeline
|
||||
- Sales or follow-up cycle stages, transition conditions, final stages
|
||||
(won / lost / archived).
|
||||
|
||||
### Domain 5 — User roles
|
||||
- Who uses the CRM (sales / manager / admin / support).
|
||||
- Rights per role: read-only / write / delete / admin.
|
||||
- Multi-tenant needed (data isolated per team)?
|
||||
|
||||
### Domain 6 — Integrations
|
||||
- Email (read / send / tracking)? Slack or other messaging?
|
||||
- CSV import/export? Inbound or outbound webhooks? External API?
|
||||
|
||||
### Domain 7 — UI/UX
|
||||
- Interface language, theme (light / dark / auto), desired dashboards
|
||||
(KPIs / charts / lists), information density preferences.
|
||||
|
||||
### Domain 8 — Deployment *(skip entirely when MODE=demo)*
|
||||
- GitHub username, Supabase project name, deployment platform
|
||||
(Vercel recommended, or GitHub Pages), custom domain.
|
||||
|
||||
---
|
||||
|
||||
## Cleanup derivation (run after all domains, before consistency checks)
|
||||
|
||||
Atomic CRM ships with default entities and features the user may not need.
|
||||
Before final validation, derive what should be removed to keep the CRM clean.
|
||||
|
||||
**Default Atomic CRM elements** (candidates for removal):
|
||||
|
||||
| Element | Remove when… |
|
||||
|---|---|
|
||||
| `company` entity | User manages individuals only (no B2B account layer) |
|
||||
| `deal` entity | No sales pipeline — user tracks contacts/support only |
|
||||
| `task` entity | User did not mention task management |
|
||||
| `tag` entity | User did not mention categorisation / labelling |
|
||||
| Pipeline / kanban view | `deal` entity is removed, or no stage-based flow requested |
|
||||
| Analytics dashboard | User did not mention KPIs, charts, or reporting |
|
||||
| CSV import/export | User did not mention data migration or bulk operations |
|
||||
| Multi-tenant / teams | Domain 5 answered "no" to data isolation per team |
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Compare domain answers against the table above. Derive a candidate removal list.
|
||||
2. Present it to the user as a **plain text message**, translated to their language.
|
||||
|
||||
**Language rule (strict):** describe only what the user *sees* in the
|
||||
interface — sections, screens, buttons. Never mention entities, tables,
|
||||
components, types, or any technical term. The user interprets the
|
||||
business meaning; you handle the technical implications internally.
|
||||
|
||||
Example (translate at runtime):
|
||||
```
|
||||
Based on what you told me, it looks like you won't need these sections
|
||||
in your CRM — I can remove them to keep things clean:
|
||||
- <plain-language feature name>: <one-sentence business reason>
|
||||
- …
|
||||
|
||||
Does that sound right? Anything you'd like to keep?
|
||||
```
|
||||
|
||||
Plain-language names to use (never the internal names):
|
||||
| Internal | Say to user |
|
||||
|---|---|
|
||||
| `company` entity | the "Companies" section |
|
||||
| `deal` entity | the "Deals" or "Sales pipeline" section |
|
||||
| `task` entity | the "Tasks" section |
|
||||
| `tag` entity | the label / tag feature |
|
||||
| Pipeline / kanban | the pipeline / kanban board view |
|
||||
| Analytics dashboard | the analytics and reporting section |
|
||||
| CSV import/export | the data import and export feature |
|
||||
| Multi-tenant | team-based data isolation |
|
||||
3. On user confirmation (or "all good"): populate `cleanup` in the JSON and Write.
|
||||
4. On user adjustment ("keep X"): remove X from the list, update JSON.
|
||||
|
||||
If no element qualifies for removal: skip this step silently (no question asked).
|
||||
|
||||
Store the result in the JSON under `"cleanup"` before writing:
|
||||
|
||||
```json
|
||||
"cleanup": {
|
||||
"entities_to_remove": ["company", "tag"],
|
||||
"features_to_disable": ["pipeline", "analytics_dashboard", "csv_import_export"]
|
||||
}
|
||||
```
|
||||
|
||||
## Consistency checks (run before final validation)
|
||||
|
||||
- No duplicate field names within the same entity.
|
||||
- Every entity referenced in `pipeline_stages` exists in `entities`.
|
||||
- Every role in `user_roles` has at least one permission defined.
|
||||
- Existing Atomic CRM entities marked `"type": "extend"`, not `"type": "create"`.
|
||||
- No service-role key or secret in client-side variables.
|
||||
|
||||
If a check fails: output one targeted `INTERVIEW:` question to fix it before
|
||||
proceeding to validation.
|
||||
|
||||
---
|
||||
|
||||
## Final validation
|
||||
|
||||
When all 8 applicable domains are `"done"`:
|
||||
|
||||
1. Read the JSON. Produce a compact plain-language summary in the user's
|
||||
language.
|
||||
2. Output a plain-text message asking for confirmation (translate at runtime):
|
||||
> *"<summary>. All good? I'll lock the project spec. (yes / no)"*
|
||||
3. On any affirmative (yes / ok / valid / go / looks good, in any language):
|
||||
a. Set `validated: true`. `Write` the file.
|
||||
b. `Bash("cd \"$CLAUDE_PROJECT_DIR\" && git add docs/project-context.json && git commit -m \"chore(setup): <fresh|update> project context\"")`
|
||||
c. Output `VALIDATED` — the orchestrator moves to SETUP-PLAN. No additional
|
||||
text after this token.
|
||||
4. On negative: ask which domain to revisit; re-enter that domain's questions.
|
||||
|
||||
---
|
||||
|
||||
## Output format (strict)
|
||||
|
||||
Every orchestrator turn during SETUP-INTERVIEW ends with **exactly one** of:
|
||||
|
||||
| Output | Meaning |
|
||||
|---|---|
|
||||
| Plain text question | Ask the user the next domain question directly; re-enter this skill on the next user turn. No wrapper, no prefix. |
|
||||
| `VALIDATED` | JSON committed. Move to STATE SETUP-PLAN. |
|
||||
| `FAILED: <reason>` | Unrecoverable error; surface to user, abort setup. |
|
||||
|
||||
---
|
||||
|
||||
## JSON schema
|
||||
|
||||
```json
|
||||
{
|
||||
"validated": false,
|
||||
"bootstrapped": false,
|
||||
"project_name": "...",
|
||||
"github_username": "...",
|
||||
"supabase_project_name": "...",
|
||||
"deploy_platform": "vercel|github-pages",
|
||||
"mode": "demo|full",
|
||||
"business_context": {
|
||||
"industry": "...",
|
||||
"team_size": 0,
|
||||
"client_type": "B2B|B2C|mixed",
|
||||
"objective": "..."
|
||||
},
|
||||
"entities": [
|
||||
{
|
||||
"name": "ticket",
|
||||
"type": "create|extend",
|
||||
"base_entity": null,
|
||||
"fields": [
|
||||
{ "name": "subject", "type": "text", "required": true }
|
||||
]
|
||||
}
|
||||
],
|
||||
"pipeline_stages": ["open", "in_progress", "resolved"],
|
||||
"user_roles": [
|
||||
{ "name": "admin", "permissions": ["read", "write", "delete"] }
|
||||
],
|
||||
"integrations": [],
|
||||
"ui": { "language": "en", "theme": "light|dark|auto" },
|
||||
"cleanup": {
|
||||
"entities_to_remove": [],
|
||||
"features_to_disable": []
|
||||
},
|
||||
"interview_progress": {
|
||||
"domain_1": "done",
|
||||
"domain_2": "pending",
|
||||
"domain_3": "pending",
|
||||
"domain_4": "pending",
|
||||
"domain_5": "pending",
|
||||
"domain_6": "pending",
|
||||
"domain_7": "pending",
|
||||
"domain_8": "pending"
|
||||
},
|
||||
"phase_status": {
|
||||
"spec": { "status": "pending" },
|
||||
"fork": { "status": "pending" },
|
||||
"supabase": { "status": "pending" },
|
||||
"env": { "status": "pending" },
|
||||
"deploy": { "status": "pending" }
|
||||
},
|
||||
"tickets": []
|
||||
}
|
||||
```
|
||||
|
||||
The `tickets` array is populated later by `planner` in SETUP_MODE — leave it
|
||||
empty here.
|
||||
|
||||
---
|
||||
|
||||
## Red Flags
|
||||
|
||||
- Asking more than one domain in a single turn, or skipping the per-domain confirmation.
|
||||
- Writing or editing any path other than `docs/project-context.json`.
|
||||
- Setting `validated: true` before the consistency checks pass.
|
||||
- Mentioning entities, tables, components, or other technical terms to the user (use plain-language feature names).
|
||||
- An entity resembling a built-in (`contact`/`company`/`deal`/`tag`/`task`/`note`) marked `"type": "create"` instead of `"extend"`.
|
||||
- A service-role key or secret stored in a client-side variable.
|
||||
- Emitting anything other than exactly one output contract (plain-text question / `VALIDATED` / `FAILED:`) per turn.
|
||||
|
||||
## Verification
|
||||
|
||||
Before emitting `VALIDATED`:
|
||||
|
||||
- [ ] All 8 applicable domains are `"done"` (domain 8 skipped when `MODE=demo`).
|
||||
- [ ] All consistency checks pass (no duplicate fields, pipeline entities exist, every role has a permission, built-ins use `"extend"`, no client-side secrets).
|
||||
- [ ] The `cleanup` derivation was presented in plain language and confirmed (or skipped silently when nothing qualified).
|
||||
- [ ] The user gave an affirmative final confirmation.
|
||||
- [ ] `validated: true` written, JSON committed, and `VALIDATED` is the only output (no trailing text).
|
||||
198
.claude/skills/shadcn-customization/SKILL.md
Normal file
198
.claude/skills/shadcn-customization/SKILL.md
Normal file
@@ -0,0 +1,198 @@
|
||||
---
|
||||
name: shadcn-customization
|
||||
description: Shadcn/ui theming and component customization — CSS variables, OKLCH colors, dark mode, variants, wrappers. Load for any ticket involving colors, theme, UI layout, or component styling.
|
||||
---
|
||||
|
||||
# Customization & Theming
|
||||
|
||||
Reference for theming and component customization. Components reference semantic CSS variable tokens change the variables to change every component. Read the relevant section, then check the Red Flags and Verification list.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Changing colors, theme, dark mode, or border radius.
|
||||
- Adding a custom color token or a new component variant.
|
||||
- Customizing or wrapping a shadcn/ui component.
|
||||
|
||||
For component *architecture* and where to import from, see `Skill({skill: "frontend-dev"})`.
|
||||
|
||||
---
|
||||
|
||||
## How It Works
|
||||
|
||||
1. CSS variables defined in `:root` (light) and `.dark` (dark mode).
|
||||
2. Tailwind maps them to utilities: `bg-primary`, `text-muted-foreground`, etc.
|
||||
3. Components use these utilities — changing a variable changes all components that reference it.
|
||||
|
||||
---
|
||||
|
||||
## Color Variables
|
||||
|
||||
Every color follows the `name` / `name-foreground` convention. The base variable is for backgrounds, `-foreground` is for text/icons on that background.
|
||||
|
||||
| Variable | Purpose |
|
||||
|---|---|
|
||||
| `--background` / `--foreground` | Page background and default text |
|
||||
| `--card` / `--card-foreground` | Card surfaces |
|
||||
| `--primary` / `--primary-foreground` | Primary buttons and actions |
|
||||
| `--secondary` / `--secondary-foreground` | Secondary actions |
|
||||
| `--muted` / `--muted-foreground` | Muted/disabled states |
|
||||
| `--accent` / `--accent-foreground` | Hover and accent states |
|
||||
| `--destructive` / `--destructive-foreground` | Error and destructive actions |
|
||||
| `--border` | Default border color |
|
||||
| `--input` | Form input borders |
|
||||
| `--ring` | Focus ring color |
|
||||
| `--chart-1` through `--chart-5` | Chart/data visualization |
|
||||
| `--sidebar-*` | Sidebar-specific colors |
|
||||
| `--surface` / `--surface-foreground` | Secondary surface |
|
||||
|
||||
Colors use OKLCH: `--primary: oklch(0.205 0 0)` where values are lightness (0–1), chroma (0 = gray), and hue (0–360).
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode
|
||||
|
||||
Class-based toggle via `.dark` on the root element. Atomic CRM wires this
|
||||
through its own `ThemeProvider` at `src/components/admin/theme-provider.tsx`,
|
||||
which is already mounted from `src/components/admin/admin.tsx`. To customize
|
||||
the palette, pass `lightTheme` / `darkTheme` props to the `<CRM>` component
|
||||
(see `src/App.tsx`).
|
||||
|
||||
---
|
||||
|
||||
## Changing the Theme
|
||||
|
||||
```bash
|
||||
# Apply a preset from ui.shadcn.com.
|
||||
npx shadcn@latest apply --preset a2r6bw
|
||||
|
||||
# Positional shorthand also works.
|
||||
npx shadcn@latest apply a2r6bw
|
||||
|
||||
# Switch to a named preset, overwrite existing components.
|
||||
npx shadcn@latest apply --preset nova
|
||||
|
||||
# Preserve existing components instead.
|
||||
npx shadcn@latest init --preset nova --force --no-reinstall
|
||||
|
||||
# Use a custom theme URL.
|
||||
npx shadcn@latest apply --preset "https://ui.shadcn.com/init?base=radix&style=nova&theme=blue&..."
|
||||
```
|
||||
|
||||
Or edit CSS variables directly in `globals.css`.
|
||||
|
||||
---
|
||||
|
||||
## Adding Custom Colors
|
||||
|
||||
Add variables to the file at `tailwindCssFile` from `npx shadcn@latest info` (typically `globals.css`). Never create a new CSS file for this.
|
||||
|
||||
```css
|
||||
/* 1. Define in the global CSS file. */
|
||||
:root {
|
||||
--warning: oklch(0.84 0.16 84);
|
||||
--warning-foreground: oklch(0.28 0.07 46);
|
||||
}
|
||||
.dark {
|
||||
--warning: oklch(0.41 0.11 46);
|
||||
--warning-foreground: oklch(0.99 0.02 95);
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* 2. Register with Tailwind v4 (@theme inline). */
|
||||
@theme inline {
|
||||
--color-warning: var(--warning);
|
||||
--color-warning-foreground: var(--warning-foreground);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// 3. Use in components.
|
||||
<div className="bg-warning text-warning-foreground">Warning</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Border Radius
|
||||
|
||||
`--radius` controls border radius globally. Components derive values from it (`rounded-lg` = `var(--radius)`, `rounded-md` = `calc(var(--radius) - 2px)`).
|
||||
|
||||
---
|
||||
|
||||
## Customizing Components
|
||||
|
||||
Prefer these approaches in order:
|
||||
|
||||
### 1. Built-in variants
|
||||
|
||||
```tsx
|
||||
<Button variant="outline" size="sm">Click</Button>
|
||||
```
|
||||
|
||||
### 2. Tailwind classes via `className`
|
||||
|
||||
```tsx
|
||||
<Card className="mx-auto max-w-md">...</Card>
|
||||
```
|
||||
|
||||
### 3. Add a new variant
|
||||
|
||||
Edit the component source to add a variant via `cva`:
|
||||
|
||||
```tsx
|
||||
// components/ui/button.tsx
|
||||
warning: "bg-warning text-warning-foreground hover:bg-warning/90",
|
||||
```
|
||||
|
||||
### 4. Wrapper components
|
||||
|
||||
Compose shadcn/ui primitives into higher-level components:
|
||||
|
||||
```tsx
|
||||
export function ConfirmDialog({ title, description, onConfirm, children }) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm}>Confirm</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checking for Updates
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button --diff
|
||||
npx shadcn@latest add button --dry-run # see all affected files
|
||||
npx shadcn@latest add button --diff button.tsx # diff for a specific file
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Red Flags
|
||||
|
||||
- A hardcoded hex/rgb color in a component instead of a semantic token (`bg-primary`, `text-muted-foreground`).
|
||||
- A new CSS file created for custom colors instead of adding them to the global CSS file.
|
||||
- A custom color used without registering it under `@theme inline` for Tailwind v4.
|
||||
- Editing component source for a one-off when a `variant` or `className` would do.
|
||||
- Palette changes wired anywhere but the `lightTheme`/`darkTheme` props on `<CRM>`.
|
||||
- A token defined in `:root` but not in `.dark` (or vice versa) — one mode is left broken.
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] Colors use semantic tokens, never hardcoded values.
|
||||
- [ ] Custom colors live in the global CSS file and are registered under `@theme inline`.
|
||||
- [ ] Every new token is defined in both `:root` and `.dark`.
|
||||
- [ ] Customization uses the lowest-effort approach that works (variant → `className` → new variant → wrapper).
|
||||
- [ ] Palette changes go through `lightTheme`/`darkTheme` on `<CRM>`.
|
||||
81
.claude/skills/update-branding/SKILL.md
Normal file
81
.claude/skills/update-branding/SKILL.md
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: update-branding
|
||||
description: Rebrand Atomic CRM — change the application logo (the wordmark in the header and on the login/signup pages) and/or the title/name. Use when the user wants to change, swap, update, or rebrand the CRM logo or title. Handles the two light/dark-mode logo variants, the three places the title is hardcoded, the config that points at them, and — optionally — the browser favicon and PWA app icons.
|
||||
---
|
||||
|
||||
# update-branding
|
||||
|
||||
## Overview
|
||||
|
||||
Changes the CRM **logo** and/or **title**. The logo shows in **two themes** across **five screens** (desktop + mobile header, login, signup, confirmation) and the title is hardcoded in **three** places a naive single-file edit leaves one stale. The steps below catch all of them.
|
||||
Exit criterion: the logo renders correctly in both themes across all five screens, and the title is consistent in the header, browser tab, and PWA name.
|
||||
|
||||
## When to Use
|
||||
|
||||
- The user wants to change, swap, or rebrand the CRM logo and/or title/name.
|
||||
- Optionally, when they also ask to update the favicon or PWA app icons.
|
||||
|
||||
Not for theme colors or component styling see `Skill({skill: "shadcn-customization"})`.
|
||||
|
||||
## Logo: two asset families
|
||||
|
||||
Confirm which the user means — most requests mean only #1.
|
||||
|
||||
1. **App logo / wordmark** (common) — `src/components/atomic-crm/root/logos/`, imported as module assets by `defaultConfiguration.ts` (Vite resolves the URL relative to the JS chunk, so it survives nested routes like `/oauth/consent` and sub-path deploys). `logo_atomic_crm_dark.svg` is **light-colored** art shown in **dark mode** + all auth pages; `logo_atomic_crm_light.svg` is **dark-colored** art shown in **light mode**. (A standalone unused `logo_atomic_crm.svg` still lives in `public/logos/`.)
|
||||
> Files are named by **the mode they display in**, not their color — the "dark-mode" logo must read on a dark background.
|
||||
2. **Favicon + PWA icons** — only if asked. Much bigger (34 PNGs); see [below](#favicon--app-icons-optional).
|
||||
|
||||
## Code defaults vs. runtime Settings — they conflict
|
||||
|
||||
- **Code defaults (this skill):** edit `defaultConfiguration.ts` + assets. Committed, applies everywhere, and the only way to change the browser-tab title / favicon / PWA icons.
|
||||
- **Settings UI** (`/settings` → Branding, [`SettingsPage.tsx`](../../../src/components/atomic-crm/settings/SettingsPage.tsx)): a logged-in user edits title + uploads logos, **per-browser**, saved to `localStorage` as `CRM.app.configuration` (logos as base64), never committed. Covers only the in-app logo + header title.
|
||||
|
||||
> ⚠️ **The store shadows the code.** `useConfigurationContext` returns `{ ...defaultConfiguration, ...storedConfig }` ([`ConfigurationContext.tsx`](../../../src/components/atomic-crm/root/ConfigurationContext.tsx)). If branding was ever saved via `/settings`, your edits to `defaultConfiguration.ts` won't show in that browser — clear the `CRM.app.configuration` localStorage key. Suspect this whenever a code change has no visual effect.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Gather the asset(s).** Ask for the file/URL, format (SVG vs PNG), and whether there are **separate light/dark variants** or one image.
|
||||
- One variant → set both config keys to it; **warn** about poor contrast in one theme (auth pages always use the dark-mode key).
|
||||
- Single-fill SVG → derive the other variant by swapping the fill (`white` ↔ `black`).
|
||||
2. **Place the files** in `src/components/atomic-crm/root/logos/`:
|
||||
- **Drop-in (simplest):** overwrite `logo_atomic_crm_dark.svg` / `logo_atomic_crm_light.svg`, keeping the exact filenames + `.svg`. The imports in `defaultConfiguration.ts` already point here — done. (SVG only.)
|
||||
- **New name / format (e.g. PNG):** save as new files next to them, then update the two `import` statements at the top of `defaultConfiguration.ts` (they feed `defaultDarkModeLogo` / `defaultLightModeLogo`; module-relative paths, so `"./logos/<file>"`). Do **not** revert them to bare public-path strings — that reintroduces issue #291.
|
||||
3. **Update the title** (if the name changes) in all **three** places — `defaultTitle` drives only the first:
|
||||
- `defaultConfiguration.ts` → `defaultTitle` — header `<h1>` + logo `alt`.
|
||||
- `index.html` → `<title>` — browser-tab title (hardcoded, not set at runtime).
|
||||
- `public/manifest.json` → `short_name` + `name` — installed-PWA name.
|
||||
|
||||
Consumers (no edits if you keep the config keys): `layout/Header.tsx` + `dashboard/MobileDashboard.tsx` render both variants (CSS-toggled, `h-6`); `login/{LoginPage,SignupPage,ConfirmationRequired}.tsx` render **only** `darkModeLogo`.
|
||||
|
||||
## Rationalizations
|
||||
|
||||
| Rationalization | Reality |
|
||||
|---|---|
|
||||
| "I changed the logo file, that's the whole job." | The logo shows in two themes across five screens and the title lives in three files. One edit leaves a stale variant. |
|
||||
| "My code change has no visual effect, so it didn't apply." | The `/settings` store shadows `defaultConfiguration.ts` per browser. Clear the `CRM.app.configuration` localStorage key. |
|
||||
| "One image is fine for both themes." | The auth pages always use the dark-mode key; a single asset reads wrong on one background. Warn or derive the second variant. |
|
||||
| "I'll regenerate a couple of favicon sizes." | The PWA set is 34 PNGs. Regenerate every size from one source or ask for the set don't hand-edit a few. |
|
||||
|
||||
## Red Flags
|
||||
|
||||
- Editing only one of the two logo asset keys, or swapping the light/dark files.
|
||||
- Changing the title in `defaultConfiguration.ts` but not `index.html` and `manifest.json`.
|
||||
- A code change with no visible effect, with the `CRM.app.configuration` store left uncleared.
|
||||
- Hand-editing a subset of favicon/PWA sizes instead of regenerating the full set.
|
||||
- Touching the docs-site logo (`doc/`) when it wasn't requested.
|
||||
|
||||
## Verification
|
||||
|
||||
`make typecheck` catches only config-key typos. Then `make start` and check: header in **both themes**, **desktop + mobile**; the **login/signup** pages (dark bg); and if the title changed, the header `<h1>`, browser tab, and PWA name. A logo wrong in exactly one theme means both keys point at one asset, or the light/dark files are swapped.
|
||||
|
||||
- [ ] Both logo variants point at the correct light/dark assets (named by display mode, not color).
|
||||
- [ ] Logo renders in both themes, desktop + mobile, and on login/signup/confirmation.
|
||||
- [ ] If the name changed, the title is updated in `defaultConfiguration.ts`, `index.html`, and `manifest.json`.
|
||||
- [ ] If no visual effect appears, the `CRM.app.configuration` localStorage key was cleared.
|
||||
- [ ] Favicon/PWA icons regenerated as a full set only if explicitly requested.
|
||||
|
||||
## Favicon & app icons (optional)
|
||||
|
||||
Only if asked. Referenced by `index.html` + `public/manifest.json`: `public/favicon.ico` and `public/appIcon/*.png` (34 sizes incl. `maskable_icon*`). Regenerate **every** size from one square source (`sharp` / `pwa-asset-generator`) or ask the user for the set — don't hand-edit a few. Confirm scope first; `theme_color`/`background_color` in `manifest.json` may also need updating.
|
||||
|
||||
> The docs-site logo (`doc/astro.config.mjs` → `doc/public/logo_atomic_crm_*.svg`) is a separate Astro site — leave it unless asked.
|
||||
54
.claude/skills/worktree-detection/SKILL.md
Normal file
54
.claude/skills/worktree-detection/SKILL.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: worktree-detection
|
||||
description: How to find a Git worktree by task number without hardcoding paths. Use in hooks or scripts that need to locate a specific task's worktree.
|
||||
---
|
||||
|
||||
# Worktree Detection
|
||||
|
||||
Reference snippet for locating a task's Git worktree from its task number,
|
||||
without hardcoding paths.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Inside a hook or script that must act on a specific task's worktree.
|
||||
- Any time you'd otherwise hardcode a `/tmp/...` worktree path.
|
||||
|
||||
## Pattern
|
||||
|
||||
```bash
|
||||
TASK_NUM="TASK-005"
|
||||
|
||||
WORKTREE=$(git worktree list --porcelain \
|
||||
| grep "^worktree " \
|
||||
| awk '{print $2}' \
|
||||
| grep -i "${TASK_NUM}" \
|
||||
| head -1)
|
||||
|
||||
if [ -z "$WORKTREE" ] || [ ! -d "$WORKTREE" ]; then
|
||||
echo "No worktree found for ${TASK_NUM} — skipping"
|
||||
exit 0
|
||||
fi
|
||||
```
|
||||
|
||||
## Why this works
|
||||
|
||||
`git worktree list` reads from Git's internal registry — it knows every worktree regardless of where it was created. No assumptions about directory structure or machine-specific paths.
|
||||
|
||||
## Extracting TASK_NUM from agent variables
|
||||
|
||||
```bash
|
||||
TASK_NUM=$(echo "$TASK_OWNER $TASK_SUBJECT" | grep -oP 'TASK-\d+' | head -1)
|
||||
```
|
||||
|
||||
Works whether the task number appears in the owner field or the subject line.
|
||||
|
||||
## Red Flags
|
||||
|
||||
- A hardcoded `/tmp/...` worktree path instead of querying `git worktree list`.
|
||||
- Assuming a directory layout rather than reading Git's registry.
|
||||
- Treating a missing worktree as an error instead of skipping cleanly (`exit 0`).
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] Worktree resolved via `git worktree list --porcelain`, never a hardcoded path.
|
||||
- [ ] A missing/absent worktree is handled (skip, don't crash).
|
||||
205
.claude/skills/writing-migrations/SKILL.md
Normal file
205
.claude/skills/writing-migrations/SKILL.md
Normal file
@@ -0,0 +1,205 @@
|
||||
---
|
||||
name: writing-migrations
|
||||
description: Generate Supabase SQL migrations at deploy time from the session branch diff. Load this when your developer dispatch asks you to generate the deploy-time migration (it points you at the shared <base>/simple worktree). This is NOT a feature ticket — the workflow below replaces the normal ticket rules: here you DO write SQL under supabase/migrations/, and you do not edit TS/TSX/CSS.
|
||||
---
|
||||
|
||||
# Writing Migrations (deploy-time round)
|
||||
|
||||
## Overview
|
||||
|
||||
A workflow that turns a session's *code* changes into the *minimal* SQL that
|
||||
makes the real Supabase schema match what the session's app now expects —
|
||||
nothing more.
|
||||
You (the `developer`) have been asked to generate the deploy-time migration. The
|
||||
"never write migrations / no schema" rule of normal ticket work does NOT apply
|
||||
here: writing SQL under `supabase/migrations/` is the whole job. Do not edit any
|
||||
TS/TSX/CSS — the schema diff already comes from the session branch; you only
|
||||
translate it to SQL.
|
||||
Exit criterion: either a committed, idempotent migration whose delta is provably the net schema change, or an explicit `NO_MIGRATION_NEEDED` when there is no schema impact.
|
||||
|
||||
Your worktree is `<WORKTREE_BASE>/simple` on `<SESSION_SHORT_ID>/simple`, forked from `session/<SESSION_SHORT_ID>` (`<WORKTREE_BASE>` =
|
||||
`/tmp/<$CLAUDE_PROJECT_DIR with every "/" replaced by "_">/<SESSION_ID>`). Every Bash call must `cd <WORKTREE_PATH> && …` (stateless shells).
|
||||
|
||||
## When to Use
|
||||
|
||||
- The deploy-time migration round, after a session's app changes have merged to `session/<SESSION_SHORT_ID>` and need their schema counterpart.
|
||||
- Triggered only by the orchestrator dispatching a `developer` that loads this skill, never invoked ad hoc by a feature developer (per-ticket developers never write SQL).
|
||||
|
||||
Skip (write nothing, report `NO_MIGRATION_NEEDED`) when the net diff has no
|
||||
schema impact: CSS, layout, copy, or test-only changes.
|
||||
|
||||
## Process
|
||||
|
||||
Each step ends in a checkpoint, do not advance until its evidence holds.
|
||||
|
||||
### 1. Compute the session's net change
|
||||
|
||||
```bash
|
||||
cd <WORKTREE_PATH>
|
||||
git diff session-base/<SESSION_SHORT_ID>..session/<SESSION_SHORT_ID>
|
||||
```
|
||||
|
||||
This is the branch's full diff since creation. Do NOT use `git merge-base`
|
||||
(it collapses after the first promotion). Do NOT diff against the base branch
|
||||
(other sessions pollute it).
|
||||
|
||||
**Checkpoint:** you have the full session diff, produced from the two-dot range above (not merge-base, not the base branch).
|
||||
|
||||
### 2. Identify schema-relevant changes
|
||||
|
||||
From that diff, keep only changes that imply a database schema change:
|
||||
- Entity TypeScript types (e.g. `src/**/types.ts`, resource type defs).
|
||||
- Fake-data generators that add/remove fields.
|
||||
- dataProvider resource registrations (new resource = new table).
|
||||
Ignore CSS, component layout, copy, tests.
|
||||
|
||||
**Checkpoint:** you have an explicit list of schema-relevant changes (possibly
|
||||
empty). If empty, jump to step 3's no-op path.
|
||||
|
||||
### 3. Regenerate ONE net migration against what is ALREADY DEPLOYED (session-base)
|
||||
|
||||
"Deployed" = the migrations present at `session-base/<SESSION_SHORT_ID>` (the fork
|
||||
anchor, i.e. the last deployed state). A migration YOU generated in an EARLIER
|
||||
iteration of this same session is NOT deployed: it sits on the session branch but was
|
||||
never pushed. Treating it as deployed is exactly what makes iterations STACK (add
|
||||
column `foo`, then a second migration to rename it to `bar`, ...) instead of one clean
|
||||
migration. The user does not want to push N migrations per change.
|
||||
|
||||
So, before authoring any SQL:
|
||||
|
||||
1. Find this session's own prior migration(s):
|
||||
`git diff --diff-filter=A session-base/<SESSION_SHORT_ID>..session/<SESSION_SHORT_ID> -- supabase/migrations/`
|
||||
(files ADDED since the fork = generated by an earlier iteration of this session).
|
||||
2. **Delete them.** You will regenerate ONE fresh file that is the whole net change.
|
||||
3. Author SQL for the NET delta between the desired schema (from the TS types) and the
|
||||
schema as of `session-base` ONLY. Never layer an incremental delta on top of your
|
||||
own prior migration.
|
||||
|
||||
Result: however many times the user iterates before deploy, the session carries
|
||||
exactly ONE migration = `net(session-base -> final schema)`. If the net diff has no
|
||||
schema impact, write **nothing** and report `NO_MIGRATION_NEEDED`.
|
||||
|
||||
**Hard boundary:** overwrite-and-regenerate is safe ONLY because the migration is not
|
||||
yet deployed. Once it is pushed / promoted it is immutable; a later change is a NEW
|
||||
forward migration, never an edit of the pushed one.
|
||||
|
||||
**Surface note.** Technical-harness writes to `supabase/migrations/`. On the web-chat
|
||||
surface (CRM Builder) the launcher stages migrations in `supabase/migrations-pending/`
|
||||
and promotes them to `supabase/migrations/` after the user confirms: follow the
|
||||
launcher's staging, do not invent your own pending/promoted handling. The
|
||||
regenerate-one-net-migration rule above applies either way (it is about NOT stacking,
|
||||
independent of which directory the launcher stages into).
|
||||
|
||||
**Checkpoint:** at most ONE session-owned migration file exists, and its SQL is the
|
||||
full net delta from `session-base` (not an increment on a prior session migration).
|
||||
|
||||
### 4. Write idempotent SQL
|
||||
|
||||
Write to `supabase/migrations/<YYYYMMDDHHMMSS>_<SESSION_SHORT_ID>_migration_<slug>.sql`
|
||||
(timestamp via `date -u +%Y%m%d%H%M%S`). Use `IF NOT EXISTS` / `IF EXISTS`,
|
||||
correct column types matching the TS types, FKs, and RLS for new tables (RLS
|
||||
enabled + policies, never `USING (true)`).
|
||||
|
||||
**Checkpoint:** re-running the migration on an already-migrated database is a
|
||||
no-op (every statement is guarded), and every new table has RLS enabled with at least one non-`USING (true)` policy.
|
||||
|
||||
### 5. View-recreation rule (BLOCKING correctness)
|
||||
|
||||
When a migration adds a column on a table referenced by a view in
|
||||
`supabase/schemas/03_views.sql`, recreate the view with **`CREATE OR REPLACE
|
||||
VIEW`** and place the new column as the **very last item** in the SELECT
|
||||
list — after every existing column AND after every existing computed `AS`
|
||||
alias (e.g. `count(...) as nb_deals`).
|
||||
|
||||
PostgreSQL forbids any ordinal shift in an existing view's SELECT list
|
||||
(error 42P16). "Between the raw columns and the computed aggregates" is
|
||||
**still a shift** — the aggregate's position increases by one. There are no
|
||||
exceptions. The mechanical rule is: positions 1..N of the new view must be
|
||||
identical to positions 1..N of the old view; the new column is position N+1.
|
||||
|
||||
Why not `DROP VIEW … CASCADE; CREATE VIEW …`? It silently drops dependent
|
||||
views/materialized views/rules, loses any explicit `REVOKE`s on the view
|
||||
(re-granted by default privileges at the next deploy without warning), and
|
||||
reads as a destructive operation in audit. Reserve it for cases `CREATE OR
|
||||
REPLACE` genuinely can't handle (column **removal** or **rename**). If you
|
||||
use it, enumerate dropped dependents in a SQL comment and re-create them in
|
||||
the same migration.
|
||||
|
||||
PostgREST queries the view, not the table — a missing update makes the
|
||||
column invisible to the app. The view's column order doesn't matter to API
|
||||
consumers (they address by name), so "append at end" has no API cost.
|
||||
|
||||
Example — adding `importance` to `companies_summary` (the view ends with
|
||||
`count(...) as nb_deals, count(...) as nb_contacts`):
|
||||
|
||||
```sql
|
||||
create or replace view public.companies_summary with (security_invoker = on) as
|
||||
select
|
||||
c.id, c.created_at, c.name, …, c.logo,
|
||||
count(distinct d.id) as nb_deals,
|
||||
count(distinct co.id) as nb_contacts,
|
||||
c.importance -- LAST, after every existing item
|
||||
from public.companies c
|
||||
left join public.deals d on c.id = d.company_id
|
||||
left join public.contacts co on c.id = co.company_id
|
||||
group by c.id;
|
||||
```
|
||||
|
||||
Then update `supabase/schemas/03_views.sql` to the **same order** — the
|
||||
declarative schema must mirror the deployed view, including the chronological
|
||||
"append at end" placement of newer columns. Don't reorder the schema file to
|
||||
look prettier — schema drift breaks future `supabase db diff` generations.
|
||||
|
||||
**Checkpoint:** for every table that gained a column AND feeds a view,
|
||||
positions 1..N of the recreated view match the old view exactly, the new column is position N+1, and `03_views.sql` mirrors that order.
|
||||
|
||||
### 6. Commit and hand off
|
||||
|
||||
Commit the SQL on `<SESSION_SHORT_ID>/simple`:
|
||||
|
||||
```bash
|
||||
cd <WORKTREE_PATH> && git add supabase/migrations && git commit -m "migration(<SESSION_SHORT_ID>): <slug>"
|
||||
```
|
||||
|
||||
Then stop and emit the output contract as your very last line — one of:
|
||||
|
||||
- `DONE: branch=<SESSION_SHORT_ID>/simple migration=<filename> summary=<what the SQL does>`
|
||||
- `NO_MIGRATION_NEEDED` — only after computing the diff (step 1) and confirming no schema impact.
|
||||
- `FAILED: <one-line reason>`
|
||||
|
||||
SubagentStop hooks (typecheck/prettier/unit/e2e) run automatically; they should
|
||||
pass since you only touched SQL. The orchestrator then sends you to
|
||||
quality-reviewer (`MODE: migration-review`) and the merger.
|
||||
|
||||
For Postgres correctness you may load `Skill({skill: "supabase-postgres-best-practices"})`.
|
||||
|
||||
**Checkpoint:** the migration is committed on `<SESSION_SHORT_ID>/simple` and the SubagentStop validation chain is green.
|
||||
|
||||
## Rationalizations
|
||||
|
||||
| Rationalization | Reality |
|
||||
|---|---|
|
||||
| "I'll diff against the base branch, it's close enough." | Other sessions pollute the base branch. Only the two-dot `session-base..session` range is the true net change. |
|
||||
| "This column is probably already deployed, I'll re-emit it to be safe." | Re-emitting a deployed change is drift, not safety. Guard with `IF NOT EXISTS` and emit only the delta. |
|
||||
| "The view still selects all the right columns, order doesn't matter." | Postgres rejects any ordinal shift (42P16). Order is a hard correctness constraint, not cosmetics. |
|
||||
| "DROP VIEW CASCADE then CREATE is simpler." | It silently drops dependents and loses REVOKEs. Use `CREATE OR REPLACE` unless you are removing/renaming a column. |
|
||||
| "No schema change, but I'll write an empty migration anyway." | A no-op deploy is valid. Write nothing and report `NO_MIGRATION_NEEDED`. |
|
||||
|
||||
## Red Flags
|
||||
|
||||
- Diff computed via `git merge-base` or against the base branch.
|
||||
- A migration statement without an `IF [NOT] EXISTS` guard.
|
||||
- A new table with no RLS, or an RLS policy using `USING (true)`.
|
||||
- A view recreated by `DROP … CASCADE` for a mere column *addition*.
|
||||
- A new column inserted anywhere but the last position of an existing view.
|
||||
- `03_views.sql` and the migration's view definition disagreeing on column order.
|
||||
- Re-emitting SQL already present in `supabase/migrations/`.
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] Diff taken from `session-base/<SESSION_SHORT_ID>..session/<SESSION_SHORT_ID>`.
|
||||
- [ ] Every emitted statement is an incremental delta not already in `supabase/migrations/`.
|
||||
- [ ] All statements are idempotent (`IF [NOT] EXISTS`).
|
||||
- [ ] New tables have RLS enabled with real policies (never `USING (true)`).
|
||||
- [ ] Affected views recreated via `CREATE OR REPLACE`, new column last, matching `03_views.sql`.
|
||||
- [ ] SQL committed on `<SESSION_SHORT_ID>/simple`; SubagentStop validation green — OR `NO_MIGRATION_NEEDED` reported with no file written.
|
||||
Reference in New Issue
Block a user