@polymorphism-tech/morph-spec 8.37.1 → 8.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +37 -13
- package/README.md +2 -2
- package/bin/morph-spec.js +75 -49
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +6 -5
- package/framework/MORPH.md +25 -22
- package/framework/agents.json +96 -36
- package/framework/docs/CLI.md +54 -17
- package/framework/evals/rubrics/code-quality.md +5 -0
- package/framework/evals/rubrics/condensed.md +6 -2
- package/framework/evals/rubrics/contracts.md +20 -0
- package/framework/evals/rubrics/test-coverage.md +17 -6
- package/framework/hooks/README.md +9 -3
- package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
- package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
- package/framework/hooks/claude-code/core/validate-completion.js +16 -90
- package/framework/hooks/claude-code/core/worktree-context.js +10 -0
- package/framework/hooks/shared/command-tokens.js +96 -0
- package/framework/hooks/shared/git-exclude.js +44 -0
- package/framework/hooks/shared/git-paths.js +52 -0
- package/framework/hooks/shared/graph-freshness.js +380 -0
- package/framework/hooks/shared/graph-refresh.js +416 -0
- package/framework/hooks/shared/morph-cli.js +17 -5
- package/framework/hooks/shared/redact-secrets.js +55 -0
- package/framework/hooks/shared/telemetry-logger.js +25 -2
- package/framework/hooks/shared/transcript-usage.js +80 -0
- package/framework/mcp/registry.json +33 -2
- package/framework/rubrics/backend.json +2 -2
- package/framework/rubrics/default.json +2 -2
- package/framework/rubrics/frontend.json +2 -2
- package/framework/rules/csharp-standards.md +1 -0
- package/framework/rules/infrastructure-standards.md +0 -1
- package/framework/rules/morph-workflow.md +10 -4
- package/framework/rules/nextjs-standards.md +1 -0
- package/framework/schemas/config.schema.json +41 -1
- package/framework/schemas/feature.schema.json +5 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +40 -11
- package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +44 -10
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +73 -15
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +51 -13
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +39 -13
- package/framework/standards/STANDARDS.json +189 -14
- package/framework/standards/ai-agents/context-providers.md +16 -26
- package/framework/standards/ai-agents/durable-workflows-hitl.md +15 -21
- package/framework/standards/ai-agents/modalities-image-gen.md +21 -25
- package/framework/standards/ai-agents/modalities-speech-to-text.md +4 -15
- package/framework/standards/ai-agents/modalities-text-to-speech.md +2 -11
- package/framework/standards/ai-agents/modalities-vision.md +1 -18
- package/framework/standards/ai-agents/prompt-sources.md +0 -4
- package/framework/standards/ai-agents/testing-ai.md +2 -1
- package/framework/standards/architecture/code-comments.md +100 -0
- package/framework/standards/backend/dotnet/testing.md +22 -0
- package/framework/standards/backend/integrations/neon-auth/neon-auth.md +63 -50
- package/framework/standards/frontend/admin-app/data-table.md +562 -0
- package/framework/standards/frontend/admin-app/feedback.md +236 -0
- package/framework/standards/frontend/admin-app/forms.md +232 -0
- package/framework/standards/frontend/admin-app/page-structure.md +203 -0
- package/framework/standards/frontend/design-system/ai-image-generation.md +5 -5
- package/framework/standards/frontend/nextjs/components.md +37 -58
- package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
- package/framework/standards/frontend/nextjs/forms.md +31 -30
- package/framework/standards/frontend/nextjs/state-management.md +2 -1
- package/framework/standards/frontend/nextjs/testing.md +18 -0
- package/framework/standards/integration/mcp/mcp-tools.md +48 -117
- package/framework/templates/code/dotnet/abstractions/result.cs +0 -2
- package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +26 -39
- package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +28 -68
- package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +99 -117
- package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +2 -44
- package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +40 -61
- package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +54 -107
- package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +53 -58
- package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +192 -281
- package/framework/templates/code/dotnet/database/migration.cs +49 -83
- package/framework/templates/code/dotnet/database/vector-entity.cs +2 -46
- package/framework/templates/code/dotnet/extensions/result-extensions.cs +0 -2
- package/framework/templates/code/dotnet/feature/validator.cs.hbs +11 -15
- package/framework/templates/code/dotnet/jobs/agent.cs +3 -51
- package/framework/templates/code/dotnet/jobs/job.cs +2 -97
- package/framework/templates/code/dotnet/test.cs +2 -87
- package/framework/templates/code/typescript/contracts.ts +140 -167
- package/framework/templates/context/design-system.md +3 -3
- package/framework/templates/docs/proposal.md +11 -0
- package/framework/templates/docs/spec.md +15 -0
- package/framework/templates/docs/ui-mockups.md +115 -98
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentApi.cs +1 -48
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactory.cs +3 -79
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactoryOptions.cs +1 -70
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpec.cs +1 -47
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpecOptions.cs +2 -99
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/AiKitAssemblyMarker.cs +0 -6
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/ConversationStore.cs +3 -88
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/IConversationStore.cs +1 -41
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/InMemoryConversationStore.cs +0 -21
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/CountingChatClient.cs +1 -25
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalAssertion.cs +2 -63
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheMissException.cs +0 -30
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheOptions.cs +1 -52
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheStamp.cs +3 -78
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalFixture.cs +4 -109
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalMode.cs +0 -22
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalQualityEvaluators.cs +1 -36
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalScenario.cs +2 -57
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalSummary.cs +0 -75
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/OfflineChatClient.cs +2 -59
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/ShortPathResponseCache.cs +4 -111
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaGenerator.cs +0 -34
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaProvider.cs +1 -57
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaArtifact.cs +0 -19
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaDisclosure.cs +2 -74
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaGenerator.cs +2 -33
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaIdempotencyKey.cs +1 -43
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJob.cs +1 -96
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobId.cs +0 -13
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobState.cs +0 -27
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaPricing.cs +0 -33
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaProvenance.cs +0 -31
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaRequest.cs +3 -75
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaResult.cs +0 -42
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaReview.cs +0 -39
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaSeams.cs +1 -55
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaStore.cs +3 -59
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MorphMedia.cs +0 -46
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/GoogleImageProvider.cs +3 -28
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/OpenAIImageProvider.cs +7 -61
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/VeoVideoProvider.cs +2 -37
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/IUsageCostSink.cs +1 -23
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostMiddleware.cs +2 -82
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostRecord.cs +1 -68
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphOpenTelemetryExtensions.cs +1 -62
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphTelemetryOptions.cs +2 -62
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/IProviderClientFactory.cs +1 -25
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelAlias.cs +0 -124
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelRegistry.cs +1 -73
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaIngest.cs +1 -66
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +0 -41
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentFactoryTests.cs +2 -112
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentSpecTests.cs +2 -43
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ConversationStoreTests.cs +5 -110
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/DeltaIngestTests.cs +7 -103
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalAssertionTests.cs +1 -42
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheMissTests.cs +1 -30
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheStampTests.cs +1 -19
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFailuresTests.cs +0 -48
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFixtureTests.cs +1 -29
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalRefreshTests.cs +2 -38
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalWorkspace.cs +1 -14
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/KitEvals.cs +4 -69
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/OfflineChatClientTests.cs +1 -10
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClient.cs +1 -57
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClientTests.cs +0 -26
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeClock.cs +1 -19
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeMediaProvider.cs +0 -32
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeProviderClientFactory.cs +0 -14
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeUsageCostSink.cs +0 -24
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/InMemoryMediaStore.cs +0 -32
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAccessibilityTests.cs +0 -37
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAliasIsNotAnAgentTests.cs +0 -24
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +0 -78
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaGeneratorTests.cs +0 -53
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaIdempotencyKeyTests.cs +0 -44
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaJobStateTests.cs +0 -44
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaReviewTests.cs +0 -19
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaStoreTests.cs +0 -24
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ModelRegistryTests.cs +6 -48
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/SkeletonSmokeTests.cs +1 -13
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/TelemetryTests.cs +2 -71
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/UsageCostMiddlewareTests.cs +5 -100
- package/framework/templates/feature/decisions.md +124 -122
- package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +39 -43
- package/framework/templates/frontend/nextjs/env.mjs.hbs +29 -32
- package/framework/templates/frontend/nextjs/page.tsx.hbs +21 -22
- package/framework/templates/frontend/nextjs/use-feature.ts.hbs +42 -54
- package/framework/templates/integrations/asaas-client.cs +332 -387
- package/framework/templates/integrations/asaas-webhook.cs +290 -351
- package/framework/templates/integrations/neon-auth-config.cs +9 -105
- package/framework/templates/project/validate.js +97 -120
- package/package.json +2 -2
- package/src/commands/dashboard/dashboard.js +80 -53
- package/src/commands/graph/affected.js +4 -3
- package/src/commands/graph/build.js +109 -18
- package/src/commands/graph/explain.js +8 -5
- package/src/commands/graph/status.js +14 -8
- package/src/commands/mcp/mcp-setup.js +37 -9
- package/src/commands/observability/cost.js +148 -18
- package/src/commands/project/doctor.js +85 -15
- package/src/commands/project/init.js +6 -3
- package/src/commands/project/install-plugin-cmd.js +1 -1
- package/src/commands/project/update.js +30 -0
- package/src/commands/project/worktree.js +73 -90
- package/src/commands/state/advance.js +18 -0
- package/src/commands/state/approve.js +15 -0
- package/src/commands/state/archive.js +62 -9
- package/src/commands/state/finish.js +62 -14
- package/src/commands/state/gate-check.js +8 -1
- package/src/commands/state/gate-decision.js +8 -4
- package/src/commands/state/report-check.js +90 -9
- package/src/commands/state/usecase.js +66 -0
- package/src/commands/tasks/dag.js +17 -2
- package/src/commands/validation/validate-feature.js +6 -1
- package/src/commands/validation/verify.js +20 -1
- package/src/dashboard/app/App.jsx +80 -16
- package/src/dashboard/app/components/ActivityFeed.jsx +172 -0
- package/src/dashboard/app/components/CommandPalette.jsx +155 -0
- package/src/dashboard/app/components/DetailPanel.jsx +47 -11
- package/src/dashboard/app/components/EntityLink.jsx +55 -0
- package/src/dashboard/app/components/ErrorBoundary.jsx +54 -0
- package/src/dashboard/app/components/FlowGraph.jsx +19 -17
- package/src/dashboard/app/components/KnowledgeMarkdown.jsx +53 -0
- package/src/dashboard/app/components/Markdown.jsx +47 -0
- package/src/dashboard/app/components/PhaseRail.jsx +111 -0
- package/src/dashboard/app/components/SearchFilter.jsx +40 -22
- package/src/dashboard/app/components/Sidebar.jsx +146 -37
- package/src/dashboard/app/hooks/useMorphQuery.js +54 -0
- package/src/dashboard/app/hooks/usePref.js +38 -0
- package/src/dashboard/app/lib/format.js +69 -0
- package/src/dashboard/app/lib/phases.js +56 -0
- package/src/dashboard/app/lib/project.js +50 -0
- package/src/dashboard/app/pages/Activity.jsx +24 -0
- package/src/dashboard/app/pages/AgentMap.jsx +371 -370
- package/src/dashboard/app/pages/FeatureDetail.jsx +604 -0
- package/src/dashboard/app/pages/Features.jsx +400 -0
- package/src/dashboard/app/pages/Guide.jsx +146 -0
- package/src/dashboard/app/pages/HookInspector.jsx +357 -322
- package/src/dashboard/app/pages/PhasePipeline.jsx +277 -408
- package/src/dashboard/app/pages/Portfolio.jsx +156 -0
- package/src/dashboard/app/pages/Rubrics.jsx +101 -0
- package/src/dashboard/app/pages/RulesMap.jsx +300 -273
- package/src/dashboard/app/pages/SkillsLibrary.jsx +483 -366
- package/src/dashboard/app/pages/Standards.jsx +407 -332
- package/src/dashboard/app/styles/index.css +187 -47
- package/src/dashboard/index.html +14 -12
- package/src/dashboard/package.json +32 -24
- package/src/dashboard/scanner/activity-scanner.js +351 -0
- package/src/dashboard/scanner/agents-scanner.js +63 -1
- package/src/dashboard/scanner/docs-scanner.js +59 -0
- package/src/dashboard/scanner/feature-status.js +10 -0
- package/src/dashboard/scanner/features-scanner.js +360 -0
- package/src/dashboard/scanner/hooks-scanner.js +8 -1
- package/src/dashboard/scanner/index.js +14 -7
- package/src/dashboard/scanner/phases-scanner.js +14 -15
- package/src/dashboard/scanner/portfolio.js +153 -0
- package/src/dashboard/scanner/project-api.js +195 -0
- package/src/dashboard/scanner/rules-scanner.js +42 -37
- package/src/dashboard/scanner/skills-scanner.js +105 -43
- package/src/dashboard/scanner/standards-scanner.js +4 -1
- package/src/dashboard/vite-plugin-morph-data.js +134 -17
- package/src/lib/ai/comment-stripper.js +101 -4
- package/src/lib/detectors/claude-config-detector.js +2 -7
- package/src/lib/detectors/index.js +5 -1
- package/src/lib/evaluator-report.js +47 -0
- package/src/lib/generators/handoff-generator.js +4 -18
- package/src/lib/generators/settings-generator.js +208 -209
- package/src/lib/git/default-branch.js +52 -0
- package/src/lib/git/tree-fingerprint.js +58 -0
- package/src/lib/git/tree-state.js +167 -0
- package/src/lib/graph/god-nodes.js +12 -3
- package/src/lib/graph/graph-health.js +48 -2
- package/src/lib/graph/graph-loader.js +99 -25
- package/src/lib/graph/graphifyignore.js +145 -28
- package/src/lib/graph/sanitize.js +117 -11
- package/src/lib/installers/mcp-installer.js +724 -719
- package/src/lib/installers/memory-installer.js +1 -2
- package/src/lib/projects/registry.js +154 -0
- package/src/lib/risk-detector.js +90 -0
- package/src/lib/specs/acervo-format.js +70 -0
- package/src/lib/specs/acervo-lifecycle.js +264 -0
- package/src/lib/specs/adr-index.js +169 -0
- package/src/lib/specs/revocations.js +156 -0
- package/src/lib/specs/screen-checklist.js +357 -0
- package/src/lib/specs/usecase-check.js +20 -16
- package/src/lib/specs/usecase-promoter.js +103 -38
- package/src/lib/specs/usecase-registry.js +7 -7
- package/src/lib/tasks/tasks-json-parser.js +2 -3
- package/src/lib/telemetry/metrics.js +386 -0
- package/src/lib/telemetry/telemetry-reader.js +161 -51
- package/src/lib/telemetry/verify-event.js +79 -0
- package/src/lib/validators/code-budget/code-budget-validator.js +200 -0
- package/src/lib/validators/code-budget/comment-scanner.js +75 -0
- package/src/lib/validators/code-budget/index.js +9 -0
- package/src/lib/validators/reuse/reuse-validator.js +4 -2
- package/src/lib/validators/reuse/symbol-scanner.js +27 -22
- package/src/lib/validators/validation-runner.js +21 -5
- package/src/lib/validators/verify-runner.js +11 -0
- package/src/lib/worktree/finish-backup.js +739 -0
- package/src/lib/worktree/ignored-triage.js +15 -8
- package/src/lib/worktree/worktree-audit.js +5 -0
- package/src/scripts/install-plugin.js +1 -1
- package/src/scripts/setup-infra.js +31 -22
- package/src/utils/agents-installer.js +338 -24
- package/src/utils/banner.js +1 -1
- package/src/utils/hooks-installer.js +75 -15
- package/src/commands/index.js +0 -13
- package/src/commands/project/checkpoint.js +0 -209
- package/src/core/state/phase-state-machine.js +0 -197
- package/src/dashboard/app/hooks/useDetailPanel.js +0 -15
- package/src/dashboard/app/pages/LiveState.jsx +0 -206
- package/src/dashboard/scanner/squads-scanner.js +0 -13
- package/src/dashboard/scanner/state-scanner.js +0 -85
- package/src/lib/generators/index.js +0 -10
- package/src/lib/hooks/hook-executor.js +0 -167
- package/src/lib/validators/checkpoint-persistence.js +0 -39
- package/src/lib/validators/shared/emit-validator-dispatch.js +0 -14
- package/src/lib/validators/shared/index.js +0 -12
- package/src/lib/validators/shared/issue-counter.js +0 -14
- package/src/lib/validators/shared/result-formatter.js +0 -124
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Admin App — Confirmation Dialogs and Toast Messages
|
|
2
|
+
|
|
3
|
+
> **Scope:** frontend/admin-app
|
|
4
|
+
> **Layer:** 2 (on keyword)
|
|
5
|
+
> **Keywords:** confirmation dialog, confirm, alert dialog, destructive action, delete confirmation, undo, toast, sonner, notification, feedback message, message catalog
|
|
6
|
+
> **Load When:** an admin screen deletes, archives or bulk-changes records, or reports the outcome of an action
|
|
7
|
+
|
|
8
|
+
**Verified against:** shadcn 4 `AlertDialog` (Radix) + sonner 2 + TanStack Query 5 (the code blocks typecheck under `tsc --strict`). Last-verified: 2026-09-24.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
Two decisions every admin action makes, and that each screen used to make on its own: **does this need a confirmation?** and **what does the user read when it finishes?** Six hand-made modals and a dozen ad-hoc toast strings is what "each screen decides" produces. This standard fixes one confirmation component and one message catalog.
|
|
13
|
+
|
|
14
|
+
## Core Rules
|
|
15
|
+
|
|
16
|
+
- ONE `components/confirm-dialog.tsx`. No hand-made modal for "are you sure?".
|
|
17
|
+
- Confirm only what is **destructive and not undoable**, what acts on **many records**, or what **discards unsaved work**. A reversible action gets an "Desfazer" toast instead of a question.
|
|
18
|
+
- The confirm button names the verb and the object ("Excluir produto"), never "OK"/"Sim".
|
|
19
|
+
- The dialog stays open while the action runs and **stays open on failure**, showing why — it never closes first and fails silently behind the user.
|
|
20
|
+
- EVERY toast string comes from the catalog (`lib/toast-messages.ts` + `features/*/messages.ts`). No string literal in `toast()` calls.
|
|
21
|
+
- Field validation never goes to a toast — it belongs on the field (`admin-app/forms.md`).
|
|
22
|
+
|
|
23
|
+
## When to Confirm
|
|
24
|
+
|
|
25
|
+
| Action | Confirmation | After |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| Delete one record, reversible (soft delete / archive) | none | toast with **Desfazer** |
|
|
28
|
+
| Delete one record, irreversible | `ConfirmDialog`, destructive | toast "excluído" |
|
|
29
|
+
| Bulk action on the selected rows | `ConfirmDialog` stating the count | toast with the count |
|
|
30
|
+
| Action on "everything matching the filter" (server-side) | `ConfirmDialog` + **type the count** to enable the button | toast with the count |
|
|
31
|
+
| Close a dirty form | `ConfirmDialog` "Descartar alterações?" | — |
|
|
32
|
+
| Save, create, toggle a flag | none | toast (or silence for a toggle whose new state is visible) |
|
|
33
|
+
|
|
34
|
+
## The Confirmation Dialog (Tier 2)
|
|
35
|
+
|
|
36
|
+
Controlled, so it can await the mutation: the confirm button is a plain `Button`, not `AlertDialogAction` — the Radix action closes the dialog on click, before the result is known.
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
// components/confirm-dialog.tsx
|
|
40
|
+
'use client';
|
|
41
|
+
|
|
42
|
+
import { useState } from 'react';
|
|
43
|
+
import {
|
|
44
|
+
AlertDialog,
|
|
45
|
+
AlertDialogCancel,
|
|
46
|
+
AlertDialogContent,
|
|
47
|
+
AlertDialogDescription,
|
|
48
|
+
AlertDialogFooter,
|
|
49
|
+
AlertDialogHeader,
|
|
50
|
+
AlertDialogTitle,
|
|
51
|
+
} from '@/components/ui/alert-dialog';
|
|
52
|
+
import { Button } from '@/components/ui/button';
|
|
53
|
+
import { Input } from '@/components/ui/input';
|
|
54
|
+
|
|
55
|
+
export interface ConfirmDialogProps {
|
|
56
|
+
open: boolean;
|
|
57
|
+
onOpenChange: (open: boolean) => void;
|
|
58
|
+
title: string;
|
|
59
|
+
description: string;
|
|
60
|
+
confirmLabel: string;
|
|
61
|
+
destructive?: boolean;
|
|
62
|
+
/** When set, the user must type this exact text to enable the button. */
|
|
63
|
+
typeToConfirm?: string;
|
|
64
|
+
onConfirm: () => Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function ConfirmDialog(props: ConfirmDialogProps) {
|
|
68
|
+
const [pending, setPending] = useState(false);
|
|
69
|
+
const [error, setError] = useState<string | null>(null);
|
|
70
|
+
const [typed, setTyped] = useState('');
|
|
71
|
+
const blocked = props.typeToConfirm !== undefined && typed !== props.typeToConfirm;
|
|
72
|
+
|
|
73
|
+
async function confirm() {
|
|
74
|
+
setPending(true);
|
|
75
|
+
setError(null);
|
|
76
|
+
try {
|
|
77
|
+
await props.onConfirm();
|
|
78
|
+
props.onOpenChange(false);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
setError(e instanceof Error ? e.message : 'Não foi possível concluir. Tente de novo.');
|
|
81
|
+
} finally {
|
|
82
|
+
setPending(false);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<AlertDialog
|
|
88
|
+
open={props.open}
|
|
89
|
+
onOpenChange={(open) => {
|
|
90
|
+
if (pending) return;
|
|
91
|
+
if (!open) { setTyped(''); setError(null); }
|
|
92
|
+
props.onOpenChange(open);
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
<AlertDialogContent>
|
|
96
|
+
<AlertDialogHeader>
|
|
97
|
+
<AlertDialogTitle>{props.title}</AlertDialogTitle>
|
|
98
|
+
<AlertDialogDescription>{props.description}</AlertDialogDescription>
|
|
99
|
+
</AlertDialogHeader>
|
|
100
|
+
{props.typeToConfirm !== undefined && (
|
|
101
|
+
<Input
|
|
102
|
+
aria-label={`Digite ${props.typeToConfirm} para confirmar`}
|
|
103
|
+
value={typed}
|
|
104
|
+
onChange={(e) => setTyped(e.target.value)}
|
|
105
|
+
autoComplete="off"
|
|
106
|
+
/>
|
|
107
|
+
)}
|
|
108
|
+
{error && <p role="alert" className="text-sm text-destructive">{error}</p>}
|
|
109
|
+
<AlertDialogFooter>
|
|
110
|
+
<AlertDialogCancel disabled={pending}>Cancelar</AlertDialogCancel>
|
|
111
|
+
<Button
|
|
112
|
+
variant={props.destructive ? 'destructive' : 'default'}
|
|
113
|
+
disabled={pending || blocked}
|
|
114
|
+
onClick={confirm}
|
|
115
|
+
>
|
|
116
|
+
{pending ? 'Aguarde…' : props.confirmLabel}
|
|
117
|
+
</Button>
|
|
118
|
+
</AlertDialogFooter>
|
|
119
|
+
</AlertDialogContent>
|
|
120
|
+
</AlertDialog>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Writing the dialog's text:
|
|
126
|
+
|
|
127
|
+
- **Title** is the question, naming the object: *Excluir o produto "Camiseta azul"?* — for bulk, the count: *Excluir 12 produtos?*
|
|
128
|
+
- **Description** is the consequence, in one sentence: *Ele sai do catálogo e dos pedidos em aberto. Não dá para desfazer.*
|
|
129
|
+
- **Confirm label** repeats the verb: *Excluir produto* / *Excluir 12 produtos*.
|
|
130
|
+
|
|
131
|
+
## The Toast Catalog
|
|
132
|
+
|
|
133
|
+
Messages are data, reviewed in one place, and never built from a server's raw error text.
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
// lib/toast-messages.ts — the shape every entity follows, plus the generic outcomes
|
|
137
|
+
export interface EntityMessages {
|
|
138
|
+
created: (name: string) => string;
|
|
139
|
+
updated: (name: string) => string;
|
|
140
|
+
deleted: (count: number) => string;
|
|
141
|
+
archived: (count: number) => string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const genericMessages = {
|
|
145
|
+
saveFailed: 'Não foi possível salvar. Tente de novo.',
|
|
146
|
+
loadFailed: 'Não foi possível carregar os dados.',
|
|
147
|
+
network: 'Sem conexão com o servidor. Verifique a rede e tente de novo.',
|
|
148
|
+
forbidden: 'Você não tem permissão para esta ação.',
|
|
149
|
+
conflict: 'Outra pessoa alterou este registro. Recarregue para ver a versão atual.',
|
|
150
|
+
undone: 'Ação desfeita.',
|
|
151
|
+
} as const;
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
// features/products/messages.ts
|
|
156
|
+
import type { EntityMessages } from '@/lib/toast-messages';
|
|
157
|
+
|
|
158
|
+
export const productMessages: EntityMessages = {
|
|
159
|
+
created: (name) => `Produto "${name}" criado.`,
|
|
160
|
+
updated: (name) => `Produto "${name}" salvo.`,
|
|
161
|
+
deleted: (count) => (count === 1 ? 'Produto excluído.' : `${count} produtos excluídos.`),
|
|
162
|
+
archived: (count) => (count === 1 ? 'Produto arquivado.' : `${count} produtos arquivados.`),
|
|
163
|
+
};
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Per-entity functions, not one generic `created(entity)`: Portuguese agrees in gender and number (*produto criado*, *categoria criada*), and a template that concatenates the noun gets it wrong half the time.
|
|
167
|
+
|
|
168
|
+
Showing them — sonner's API, always with a catalog string:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
// features/products/hooks/use-archive-products.ts
|
|
172
|
+
'use client';
|
|
173
|
+
|
|
174
|
+
import { useMutation } from '@tanstack/react-query';
|
|
175
|
+
import { useRouter } from 'next/navigation';
|
|
176
|
+
import { toast } from 'sonner';
|
|
177
|
+
import { throwIfNotOk } from '@/lib/api/api-error';
|
|
178
|
+
import { PROXY_API_PREFIX } from '@/lib/api/prefixes';
|
|
179
|
+
import { genericMessages } from '@/lib/toast-messages';
|
|
180
|
+
import { productMessages } from '@/features/products/messages';
|
|
181
|
+
|
|
182
|
+
export function useArchiveProducts() {
|
|
183
|
+
const router = useRouter();
|
|
184
|
+
return useMutation({
|
|
185
|
+
mutationFn: async (ids: string[]) => {
|
|
186
|
+
await throwIfNotOk(await fetch(`${PROXY_API_PREFIX}/products/archive`, {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
headers: { 'Content-Type': 'application/json' },
|
|
189
|
+
body: JSON.stringify({ ids }),
|
|
190
|
+
}));
|
|
191
|
+
return ids;
|
|
192
|
+
},
|
|
193
|
+
onSuccess: (ids) => {
|
|
194
|
+
router.refresh();
|
|
195
|
+
toast.success(productMessages.archived(ids.length), {
|
|
196
|
+
id: 'products-archive',
|
|
197
|
+
action: { label: 'Desfazer', onClick: () => void restore(ids).then(() => router.refresh()) },
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
onError: () => toast.error(genericMessages.saveFailed, { id: 'products-archive', duration: 8000 }),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function restore(ids: string[]) {
|
|
205
|
+
await throwIfNotOk(await fetch(`${PROXY_API_PREFIX}/products/restore`, {
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: { 'Content-Type': 'application/json' },
|
|
208
|
+
body: JSON.stringify({ ids }),
|
|
209
|
+
}));
|
|
210
|
+
toast(genericMessages.undone);
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Toast Rules
|
|
215
|
+
|
|
216
|
+
- **Success:** short, past tense, names the object or the count. Default duration.
|
|
217
|
+
- **Error:** says what failed and what to do next; longer `duration` so it can be read. Map by status: 403 → `forbidden`, 409 → `conflict`, network failure → `network`, anything else → `saveFailed`/`loadFailed`.
|
|
218
|
+
- **One toast per action:** pass an `id` so a retried action replaces its toast instead of stacking a second one.
|
|
219
|
+
- **Undo** lives in the toast's `action`, and only where the backend can really undo (archive/restore, soft delete). A "Desfazer" that cannot undo is worse than none.
|
|
220
|
+
- **Silence is an answer** for a toggle whose new state is already visible in the row.
|
|
221
|
+
- The `<Toaster />` (shadcn `sonner`) is mounted once, in the root admin layout.
|
|
222
|
+
|
|
223
|
+
## Common Mistakes
|
|
224
|
+
|
|
225
|
+
| Wrong | Right | Why |
|
|
226
|
+
|---|---|---|
|
|
227
|
+
| A new modal per screen | `ConfirmDialog` | Six modals, six behaviours for pending and failure |
|
|
228
|
+
| `AlertDialogAction` running the async delete | Plain `Button` in a controlled dialog | The action closes the dialog before the result exists |
|
|
229
|
+
| "Tem certeza?" / "OK" | Question naming the object + verb on the button | The user confirms without reading what |
|
|
230
|
+
| Confirm on every action | Confirm only destructive/bulk/discard; undo toast for the reversible | Confirmation fatigue makes people click through the one that matters |
|
|
231
|
+
| `toast.error(err.message)` | Catalog message by status | Server text leaks internals and is not written for the user |
|
|
232
|
+
| `toast('Salvo!')` literal in the component | `productMessages.updated(name)` | Wording drifts screen by screen, and nobody reviews it |
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
*MORPH-SPEC by Polymorphism Tech*
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Admin App — Forms
|
|
2
|
+
|
|
3
|
+
> **Scope:** frontend/admin-app
|
|
4
|
+
> **Layer:** 2 (on keyword)
|
|
5
|
+
> **Keywords:** admin form, crud form, server validation, field error, setError, ValidationProblemDetails, dirty guard, unsaved changes, dialog form, sheet form, edit form, submit state
|
|
6
|
+
> **Load When:** designing or building a create/edit form in an admin panel or CRUD screen
|
|
7
|
+
|
|
8
|
+
**Verified against:** react-hook-form 7 + @hookform/resolvers 5 + Zod 4 + TanStack Query 5 + shadcn 4 `Field` (the code blocks typecheck under `tsc --strict`). Last-verified: 2026-09-24.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
An admin app has **one** form pattern. It is the base pattern of `nextjs/forms.md` — Zod schema first, `zodResolver`, react-hook-form `Controller` + shadcn `Field`/`FieldError`, submission through `useMutation` — and this standard adds only what a CRUD form needs on top of it: the server's validation errors shown **on the field they belong to**, where the form lives, when it validates, what the submit button does while saving, and the guard against losing unsaved changes. Nothing here repeats the base pattern; read it first.
|
|
13
|
+
|
|
14
|
+
## Why This Pattern and Not Another
|
|
15
|
+
|
|
16
|
+
- **The Zod schema is shared.** The same schema types the form, validates it, and parses the API response (`admin-app/data-table.md` parses the list query with Zod too). One source of truth per entity.
|
|
17
|
+
- **react-hook-form keeps inputs uncontrolled** — a 30-field form does not re-render on every keystroke, which is what `useState` per field does.
|
|
18
|
+
- **shadcn's current form docs are written around it** (`Field`, `FieldLabel`, `FieldError` + `Controller`).
|
|
19
|
+
- **Coexisting styles are the defect this rule exists for.** Measured in a real admin app: three form styles side by side (react-hook-form in some screens, hand-rolled `useState` in others) — three validation behaviours for one product, and a redesign that changed none of them. A screen that needs something the pattern cannot do registers the exception in `decisions.md`; it does not start a fourth style.
|
|
20
|
+
|
|
21
|
+
## Core Rules
|
|
22
|
+
|
|
23
|
+
- ALWAYS follow `nextjs/forms.md` for the base: schema → `z.infer`/`z.input`, `zodResolver`, `Controller` + `Field`, `useMutation`.
|
|
24
|
+
- ALWAYS map the server's validation errors onto the fields with `setError` — a 400/422 is never a generic toast when the server said which field is wrong.
|
|
25
|
+
- ALWAYS `mode: 'onTouched'`: a field validates after the user leaves it, then on every change. Never shout at an untouched form.
|
|
26
|
+
- NEVER disable the submit button because the form is invalid — pressing it is how the user finds the errors. Disable it only while saving.
|
|
27
|
+
- ALWAYS guard unsaved changes when the form can be dismissed (dialog, sheet, route change).
|
|
28
|
+
- ALWAYS reuse the create schema for edit (`.partial()` / `.extend()`), never a second hand-written one.
|
|
29
|
+
|
|
30
|
+
## Server Errors on the Field
|
|
31
|
+
|
|
32
|
+
The .NET backend answers validation failures with `ValidationProblemDetails` (`{ title, status, errors: { "Name": ["..."] } }` — keys are the property names, PascalCase). One helper maps that onto the form; what does not match a field lands in the form-level error.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// lib/api/api-error.ts
|
|
36
|
+
export class ApiError extends Error {
|
|
37
|
+
constructor(public readonly status: number, public readonly body: unknown) {
|
|
38
|
+
super(`HTTP ${status}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function throwIfNotOk(res: Response): Promise<Response> {
|
|
43
|
+
if (res.ok) return res;
|
|
44
|
+
const body: unknown = await res.json().catch(() => null);
|
|
45
|
+
throw new ApiError(res.status, body);
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// lib/forms/apply-server-errors.ts
|
|
51
|
+
import type { FieldValues, Path, UseFormSetError } from 'react-hook-form';
|
|
52
|
+
import { z } from 'zod';
|
|
53
|
+
import { ApiError } from '@/lib/api/api-error';
|
|
54
|
+
|
|
55
|
+
const validationProblemSchema = z.object({
|
|
56
|
+
title: z.string().optional(),
|
|
57
|
+
errors: z.record(z.string(), z.array(z.string())).optional(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Puts each server validation message on its field. Returns false when the
|
|
62
|
+
* error is not a validation problem — the caller then shows the generic toast.
|
|
63
|
+
*/
|
|
64
|
+
export function applyServerErrors<T extends FieldValues>(
|
|
65
|
+
error: unknown,
|
|
66
|
+
setError: UseFormSetError<T>,
|
|
67
|
+
fields: ReadonlyArray<Path<T>>,
|
|
68
|
+
): boolean {
|
|
69
|
+
if (!(error instanceof ApiError) || (error.status !== 400 && error.status !== 422)) return false;
|
|
70
|
+
const problem = validationProblemSchema.safeParse(error.body);
|
|
71
|
+
if (!problem.success) return false;
|
|
72
|
+
|
|
73
|
+
const unmatched: string[] = [];
|
|
74
|
+
let focused = false;
|
|
75
|
+
for (const [key, messages] of Object.entries(problem.data.errors ?? {})) {
|
|
76
|
+
const name = key.charAt(0).toLowerCase() + key.slice(1);
|
|
77
|
+
const field = fields.find((f) => f === name);
|
|
78
|
+
if (field && messages[0]) {
|
|
79
|
+
setError(field, { type: 'server', message: messages[0] }, { shouldFocus: !focused });
|
|
80
|
+
focused = true;
|
|
81
|
+
} else {
|
|
82
|
+
unmatched.push(...messages);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (unmatched.length > 0 || !focused) {
|
|
86
|
+
setError('root.serverError', {
|
|
87
|
+
type: 'server',
|
|
88
|
+
message: unmatched.join(' ') || problem.data.title || 'Revise os campos destacados.',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
A server error set on a field clears itself the next time that field passes validation — the user fixes the value and the message goes away, with no `clearErrors` bookkeeping.
|
|
96
|
+
|
|
97
|
+
## The Form, With the Deltas
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
// features/products/components/product-form.tsx
|
|
101
|
+
'use client';
|
|
102
|
+
|
|
103
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
104
|
+
import { Controller, useForm } from 'react-hook-form';
|
|
105
|
+
import { toast } from 'sonner';
|
|
106
|
+
import { Button } from '@/components/ui/button';
|
|
107
|
+
import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field';
|
|
108
|
+
import { Input } from '@/components/ui/input';
|
|
109
|
+
import { applyServerErrors } from '@/lib/forms/apply-server-errors';
|
|
110
|
+
import { genericMessages } from '@/lib/toast-messages';
|
|
111
|
+
import { productMessages } from '@/features/products/messages';
|
|
112
|
+
import { useSaveProduct } from '@/features/products/hooks/use-save-product';
|
|
113
|
+
import {
|
|
114
|
+
productInputSchema,
|
|
115
|
+
type ProductInput,
|
|
116
|
+
type ProductInputRaw,
|
|
117
|
+
} from '@/features/products/types/product.schemas';
|
|
118
|
+
|
|
119
|
+
const FIELDS = ['name', 'sku', 'price'] as const;
|
|
120
|
+
|
|
121
|
+
export function ProductForm({ initial, onSaved }: { initial?: ProductInput & { id: string }; onSaved: () => void }) {
|
|
122
|
+
const form = useForm<ProductInputRaw, unknown, ProductInput>({
|
|
123
|
+
resolver: zodResolver(productInputSchema),
|
|
124
|
+
mode: 'onTouched',
|
|
125
|
+
defaultValues: initial ?? { name: '', sku: '', price: 0 },
|
|
126
|
+
});
|
|
127
|
+
const save = useSaveProduct(initial?.id);
|
|
128
|
+
|
|
129
|
+
function onSubmit(values: ProductInput) {
|
|
130
|
+
save.mutate(values, {
|
|
131
|
+
onSuccess: () => {
|
|
132
|
+
form.reset(values);
|
|
133
|
+
toast.success(initial ? productMessages.updated(values.name) : productMessages.created(values.name));
|
|
134
|
+
onSaved();
|
|
135
|
+
},
|
|
136
|
+
onError: (error) => {
|
|
137
|
+
if (!applyServerErrors(error, form.setError, FIELDS)) toast.error(genericMessages.saveFailed);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<form onSubmit={form.handleSubmit(onSubmit)} noValidate>
|
|
144
|
+
<FieldGroup>
|
|
145
|
+
<Controller
|
|
146
|
+
name="name"
|
|
147
|
+
control={form.control}
|
|
148
|
+
render={({ field, fieldState }) => (
|
|
149
|
+
<Field data-invalid={fieldState.invalid}>
|
|
150
|
+
<FieldLabel htmlFor={field.name}>Nome</FieldLabel>
|
|
151
|
+
<Input {...field} id={field.name} aria-invalid={fieldState.invalid} />
|
|
152
|
+
{fieldState.invalid && <FieldError errors={[fieldState.error]} />}
|
|
153
|
+
</Field>
|
|
154
|
+
)}
|
|
155
|
+
/>
|
|
156
|
+
{/* sku and price follow the same shape */}
|
|
157
|
+
{form.formState.errors.root?.serverError && (
|
|
158
|
+
<p role="alert" className="text-sm text-destructive">
|
|
159
|
+
{form.formState.errors.root.serverError.message}
|
|
160
|
+
</p>
|
|
161
|
+
)}
|
|
162
|
+
<Button type="submit" disabled={save.isPending}>
|
|
163
|
+
{save.isPending ? 'Salvando…' : 'Salvar'}
|
|
164
|
+
</Button>
|
|
165
|
+
</FieldGroup>
|
|
166
|
+
</form>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Numeric inputs arrive as strings, so the schema coerces — and a schema whose input and output types differ needs the **three** generics of `useForm` (`z.input` / `unknown` / `z.output`), or `handleSubmit` hands you the wrong type:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
// features/products/types/product.schemas.ts
|
|
175
|
+
import { z } from 'zod';
|
|
176
|
+
|
|
177
|
+
export const productInputSchema = z.object({
|
|
178
|
+
name: z.string().trim().min(2, 'Informe ao menos 2 caracteres.'),
|
|
179
|
+
sku: z.string().trim().regex(/^[A-Z0-9-]{3,20}$/, 'Use de 3 a 20 letras maiúsculas, números ou hífen.'),
|
|
180
|
+
price: z.coerce.number().positive('O preço precisa ser maior que zero.'),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
export type ProductInputRaw = z.input<typeof productInputSchema>;
|
|
184
|
+
export type ProductInput = z.output<typeof productInputSchema>;
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`form.reset(values)` after a successful save is what makes `isDirty` false again — without it the unsaved-changes guard fires on a form that was just saved.
|
|
188
|
+
|
|
189
|
+
## Where the Form Lives
|
|
190
|
+
|
|
191
|
+
| The form has… | Put it in | Why |
|
|
192
|
+
|---|---|---|
|
|
193
|
+
| ≤ 4 fields, no dependency between them | `Dialog` over the list | Quick create/edit without losing the list |
|
|
194
|
+
| 5–12 fields | `Sheet` (side panel) or its own page | Keeps the list in view; long enough to need scrolling |
|
|
195
|
+
| > 12 fields, or distinct sections | Its own page (`/products/[id]/edit`), sections or tabs (`page-structure.md`) | A modal that scrolls is a page in disguise |
|
|
196
|
+
| Steps that depend on earlier answers | Wizard (`page-structure.md`) | One screen cannot show a field whose meaning depends on a later one |
|
|
197
|
+
|
|
198
|
+
## Validation Timing and Messages
|
|
199
|
+
|
|
200
|
+
- `mode: 'onTouched'` (see Core Rules). On submit, react-hook-form focuses the first invalid field — keep `shouldFocusError` at its default.
|
|
201
|
+
- Messages say **what to do**, in the product's language: "Informe ao menos 2 caracteres." — never "Invalid input" or the Zod default.
|
|
202
|
+
- Mark the exception, not the rule: in admin forms most fields are required, so label the optional ones "(opcional)" instead of an asterisk on everything.
|
|
203
|
+
- Errors of a **field** stay on the field. A toast is for the outcome of the whole save (`feedback.md`), never for one field's validation.
|
|
204
|
+
|
|
205
|
+
## Unsaved Changes
|
|
206
|
+
|
|
207
|
+
- Dialog or sheet: intercept `onOpenChange(false)` while `form.formState.isDirty` and ask with the confirmation dialog of `feedback.md` ("Descartar alterações?").
|
|
208
|
+
- Own page: a `beforeunload` listener while dirty covers reload and tab close. In-app navigation (a sidebar link) goes through the same confirmation, triggered from the link's click handler.
|
|
209
|
+
- Never guard a form that was just saved — see `form.reset(values)` above.
|
|
210
|
+
|
|
211
|
+
## Layout and Density
|
|
212
|
+
|
|
213
|
+
- One column by default; two columns only for short, related pairs (city/state, start/end).
|
|
214
|
+
- Labels above inputs; help text with `FieldDescription`; the error right below its input.
|
|
215
|
+
- Primary action at the end of the form, aligned with the fields; "Cancelar" next to it, never a destructive action beside "Salvar".
|
|
216
|
+
- Read-only values in an edit form render as text, not as disabled inputs the user tries to click.
|
|
217
|
+
|
|
218
|
+
## Common Mistakes
|
|
219
|
+
|
|
220
|
+
| Wrong | Right | Why |
|
|
221
|
+
|---|---|---|
|
|
222
|
+
| `useState` per field / hand-rolled validation | The `nextjs/forms.md` pattern | Three form styles = three validation behaviours in one product |
|
|
223
|
+
| `toast.error(error.message)` on a 400 | `applyServerErrors` → message on the field | The user cannot see which field the server rejected |
|
|
224
|
+
| Submit disabled while the form is invalid | Disabled only while `isPending` | Pressing submit is how errors become visible |
|
|
225
|
+
| `useForm<ProductInput>` with `z.coerce` fields | `useForm<z.input, unknown, z.output>` | Input and output types differ; one generic lies about one of them |
|
|
226
|
+
| No `form.reset(values)` after save | Reset to the saved values | `isDirty` stays true and the guard nags after a successful save |
|
|
227
|
+
| Modal with 20 fields and a scrollbar | Dedicated page | A dialog is for a glance-sized task |
|
|
228
|
+
| A second, edit-only schema | `createSchema.partial()` / `.extend()` | Two schemas drift on the first new field |
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
*MORPH-SPEC by Polymorphism Tech*
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Admin App — Page Structure: Tabs, Long Pages and Wizards
|
|
2
|
+
|
|
3
|
+
> **Scope:** frontend/admin-app
|
|
4
|
+
> **Layer:** 2 (on keyword)
|
|
5
|
+
> **Keywords:** tabs, long page, detail page, section navigation, wizard, multi-step form, stepper, page layout, admin layout
|
|
6
|
+
> **Load When:** designing or building a detail page, a settings page, or a flow longer than one form
|
|
7
|
+
|
|
8
|
+
**Verified against:** Next.js 16 App Router + shadcn 4 `Tabs` (Radix) + react-hook-form 7 + Zod 4 (the code blocks typecheck under `tsc --strict`). Last-verified: 2026-09-24.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
A record with six kinds of related data rendered as one scroll becomes a 13 000 px page nobody can navigate; the same data split into tabs that each need the others becomes a hunt. The choice between **tabs**, a **long page with section navigation** and a **wizard** is a design decision per screen — the `morph-uiux` checklist asks it ("Abas") — and this standard is how to make it.
|
|
13
|
+
|
|
14
|
+
## Core Rules
|
|
15
|
+
|
|
16
|
+
- Decide per screen with the table below, and write the decision in the mockup's checklist.
|
|
17
|
+
- The active tab lives in the URL (`?tab=`): the back button and a pasted link reopen the same tab.
|
|
18
|
+
- Only the **active** tab's data is fetched. A tab strip that loads six tabs up front is a long page with extra clicks.
|
|
19
|
+
- A tab never hides a validation error: one save spanning several tabs is a long page, not tabs.
|
|
20
|
+
- A wizard keeps **one** form across its steps and validates each step before advancing; going back never loses what was typed.
|
|
21
|
+
|
|
22
|
+
## Tabs, Long Page or Wizard
|
|
23
|
+
|
|
24
|
+
| The screen… | Use | Why |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| Has ≥ 3 independent sections, each read or edited on its own (Dados, Variantes, Histórico, Acessos) | **Tabs** | The user works in one at a time; each loads alone |
|
|
27
|
+
| Has sections read together, or one save covering all of them | **Long page + section nav** | Hiding half of what one save changes is how errors go unseen |
|
|
28
|
+
| Has ≤ 2 short sections | **Plain page** | Tabs for two short blocks cost a click for nothing |
|
|
29
|
+
| Is a flow where later fields depend on earlier answers, or needs a review before committing | **Wizard** | One screen cannot show a field whose meaning a later step decides |
|
|
30
|
+
| Holds a list that can grow without bound (history, logs, accesses) | **Its own tab or page, paged** (`admin-app/data-table.md`) | An unbounded list at the bottom of a detail page is where the 13 000 px came from |
|
|
31
|
+
|
|
32
|
+
## Tabs in the URL
|
|
33
|
+
|
|
34
|
+
The page reads `tab` from `searchParams` and renders only that tab's content on the server; the client strip only writes the URL.
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
// app/(admin)/products/[id]/page.tsx
|
|
38
|
+
import { ProductTabs, PRODUCT_TABS, type ProductTab } from '@/features/products/components/product-tabs';
|
|
39
|
+
import { ProductDetailsTab } from '@/features/products/components/product-details-tab';
|
|
40
|
+
import { ProductVariantsTab } from '@/features/products/components/product-variants-tab';
|
|
41
|
+
import { ProductHistoryTab } from '@/features/products/components/product-history-tab';
|
|
42
|
+
|
|
43
|
+
export default async function ProductPage({
|
|
44
|
+
params,
|
|
45
|
+
searchParams,
|
|
46
|
+
}: {
|
|
47
|
+
params: Promise<{ id: string }>;
|
|
48
|
+
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
|
49
|
+
}) {
|
|
50
|
+
const { id } = await params;
|
|
51
|
+
const raw = (await searchParams).tab;
|
|
52
|
+
const tab: ProductTab = PRODUCT_TABS.find((t) => t === raw) ?? 'dados';
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<ProductTabs active={tab}>
|
|
56
|
+
{tab === 'dados' && <ProductDetailsTab id={id} />}
|
|
57
|
+
{tab === 'variantes' && <ProductVariantsTab id={id} />}
|
|
58
|
+
{tab === 'historico' && <ProductHistoryTab id={id} />}
|
|
59
|
+
</ProductTabs>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
// features/products/components/product-tabs.tsx
|
|
66
|
+
'use client';
|
|
67
|
+
|
|
68
|
+
import type { ReactNode } from 'react';
|
|
69
|
+
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
70
|
+
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
71
|
+
|
|
72
|
+
export const PRODUCT_TABS = ['dados', 'variantes', 'historico'] as const;
|
|
73
|
+
export type ProductTab = (typeof PRODUCT_TABS)[number];
|
|
74
|
+
|
|
75
|
+
const LABELS: Record<ProductTab, string> = { dados: 'Dados', variantes: 'Variantes', historico: 'Histórico' };
|
|
76
|
+
|
|
77
|
+
export function ProductTabs({ active, children }: { active: ProductTab; children: ReactNode }) {
|
|
78
|
+
const router = useRouter();
|
|
79
|
+
const pathname = usePathname();
|
|
80
|
+
const searchParams = useSearchParams();
|
|
81
|
+
|
|
82
|
+
function select(value: string) {
|
|
83
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
84
|
+
params.set('tab', value);
|
|
85
|
+
for (const key of ['page', 'sort', 'dir', 'q']) params.delete(key); // list state belongs to the tab being left
|
|
86
|
+
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<Tabs value={active} onValueChange={select}>
|
|
91
|
+
<TabsList>
|
|
92
|
+
{PRODUCT_TABS.map((t) => (
|
|
93
|
+
<TabsTrigger key={t} value={t}>{LABELS[t]}</TabsTrigger>
|
|
94
|
+
))}
|
|
95
|
+
</TabsList>
|
|
96
|
+
<div className="pt-4">{children}</div>
|
|
97
|
+
</Tabs>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Changing tab drops the list params of the tab being left: a `?page=4` that belonged to "Histórico" would otherwise open "Variantes" on page 4.
|
|
103
|
+
|
|
104
|
+
## Detail Page Anatomy
|
|
105
|
+
|
|
106
|
+
1. **Header:** the record's name, its status, and at most two primary actions (the rest in a `…` menu). It stays visible across tabs.
|
|
107
|
+
2. **Tabs or sections** per the decision table.
|
|
108
|
+
3. **Related lists** inside a tab are full `DataTable`s with their own URL state — never a capped preview.
|
|
109
|
+
|
|
110
|
+
## Long Page With Section Navigation
|
|
111
|
+
|
|
112
|
+
- A sticky side (desktop) or top (mobile) nav with one anchor per section; each section has an `h2` with an `id`.
|
|
113
|
+
- One save for the page, at the end **and** pinned while scrolling when the form is longer than the viewport.
|
|
114
|
+
- Collapse a section only if it is optional for most records — collapsing required fields hides errors.
|
|
115
|
+
|
|
116
|
+
## Wizard for Long Flows
|
|
117
|
+
|
|
118
|
+
One react-hook-form instance holds every step; each step lists its fields; "Próximo" validates only them with `trigger`; the final submit validates the whole schema. The step index is local UI state (`nextjs/state-management.md`): a reload loses the typed data anyway, so the URL would only lie about where the user is.
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
// features/products/components/product-wizard.tsx
|
|
122
|
+
'use client';
|
|
123
|
+
|
|
124
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
125
|
+
import { useState } from 'react';
|
|
126
|
+
import { useForm, type Path } from 'react-hook-form';
|
|
127
|
+
import { Button } from '@/components/ui/button';
|
|
128
|
+
import {
|
|
129
|
+
productInputSchema,
|
|
130
|
+
type ProductInput,
|
|
131
|
+
type ProductInputRaw,
|
|
132
|
+
} from '@/features/products/types/product.schemas';
|
|
133
|
+
|
|
134
|
+
type Step = { id: string; title: string; fields: ReadonlyArray<Path<ProductInputRaw>> };
|
|
135
|
+
|
|
136
|
+
const STEPS: ReadonlyArray<Step> = [
|
|
137
|
+
{ id: 'identificacao', title: 'Identificação', fields: ['name', 'sku'] },
|
|
138
|
+
{ id: 'preco', title: 'Preço', fields: ['price'] },
|
|
139
|
+
{ id: 'revisao', title: 'Revisão', fields: [] },
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
export function ProductWizard({ onSubmit }: { onSubmit: (values: ProductInput) => void }) {
|
|
143
|
+
const [current, setCurrent] = useState(0);
|
|
144
|
+
const form = useForm<ProductInputRaw, unknown, ProductInput>({
|
|
145
|
+
resolver: zodResolver(productInputSchema),
|
|
146
|
+
mode: 'onTouched',
|
|
147
|
+
defaultValues: { name: '', sku: '', price: 0 },
|
|
148
|
+
});
|
|
149
|
+
const step = STEPS[current];
|
|
150
|
+
const isLast = current === STEPS.length - 1;
|
|
151
|
+
|
|
152
|
+
async function next() {
|
|
153
|
+
if (!step) return;
|
|
154
|
+
const ok = await form.trigger([...step.fields], { shouldFocus: true });
|
|
155
|
+
if (ok) setCurrent((c) => Math.min(c + 1, STEPS.length - 1));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<form onSubmit={form.handleSubmit(onSubmit)} noValidate>
|
|
160
|
+
<ol className="mb-6 flex gap-4 text-sm" aria-label="Etapas">
|
|
161
|
+
{STEPS.map((s, i) => (
|
|
162
|
+
<li key={s.id} aria-current={i === current ? 'step' : undefined}
|
|
163
|
+
className={i === current ? 'font-semibold' : 'text-muted-foreground'}>
|
|
164
|
+
{i + 1}. {s.title}
|
|
165
|
+
</li>
|
|
166
|
+
))}
|
|
167
|
+
</ol>
|
|
168
|
+
{/* render the fields of `step` here, with the Controller + Field shape of nextjs/forms.md */}
|
|
169
|
+
<div className="mt-6 flex justify-between">
|
|
170
|
+
<Button type="button" variant="outline" disabled={current === 0} onClick={() => setCurrent((c) => c - 1)}>
|
|
171
|
+
Voltar
|
|
172
|
+
</Button>
|
|
173
|
+
{isLast
|
|
174
|
+
? <Button type="submit">Concluir</Button>
|
|
175
|
+
: <Button type="button" onClick={next}>Próximo</Button>}
|
|
176
|
+
</div>
|
|
177
|
+
</form>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Wizard rules:
|
|
183
|
+
|
|
184
|
+
- Show every step by name and where the user is (`aria-current="step"`); never a bare "Passo 2".
|
|
185
|
+
- "Voltar" never validates and never clears; "Próximo" validates only the current step.
|
|
186
|
+
- The last step is a **review** of everything typed, with a link back to each step — the commit happens there, not on a middle step.
|
|
187
|
+
- A flow long enough to be abandoned halfway (> ~5 steps, or one that needs data the user has to go look up) saves a **server-side draft** per step; the local form alone loses it on reload.
|
|
188
|
+
- Leaving a dirty wizard asks first (`admin-app/feedback.md`).
|
|
189
|
+
|
|
190
|
+
## Common Mistakes
|
|
191
|
+
|
|
192
|
+
| Wrong | Right | Why |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| Every related list stacked on the detail page | A tab (or page) per unbounded list, paged | The 13 000 px page |
|
|
195
|
+
| Tab state in `useState` | `?tab=` in the URL | Back button and shared links land on the wrong tab |
|
|
196
|
+
| All tabs fetched up front | Only the active tab renders and fetches | Tabs that cost as much as the long page they replaced |
|
|
197
|
+
| One form, one save, split across tabs | Long page with section nav | The error sits on a tab the user is not looking at |
|
|
198
|
+
| One `useForm` per wizard step | One form, `trigger(stepFields)` per step | Going back loses data; the final submit cannot validate the whole |
|
|
199
|
+
| Committing on a middle step | Review step commits | The user never sees what is being saved |
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
*MORPH-SPEC by Polymorphism Tech*
|