@polymorphism-tech/morph-spec 8.38.0 → 8.41.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 +20 -9
- package/README.md +2 -2
- package/bin/morph-spec.js +60 -43
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +4 -3
- package/framework/MORPH.md +10 -9
- package/framework/agents.json +43 -26
- package/framework/docs/CLI.md +7 -5
- package/framework/evals/rubrics/code-quality.md +4 -4
- package/framework/evals/rubrics/condensed.md +2 -2
- package/framework/hooks/README.md +1 -1
- package/framework/hooks/claude-code/core/telemetry-log.js +39 -8
- package/framework/hooks/claude-code/core/worktree-create.js +22 -6
- package/framework/hooks/shared/git-paths.js +38 -5
- package/framework/mcp/registry.json +58 -66
- package/framework/rules/infrastructure-standards.md +0 -1
- package/framework/rules/morph-workflow.md +9 -4
- 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 +59 -25
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
- package/framework/skills/core/morph-implement/SKILL.md +48 -24
- package/framework/skills/core/morph-media/SKILL.md +2 -2
- package/framework/skills/core/morph-plan/SKILL.md +10 -3
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- 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 +7 -6
- package/framework/standards/PROVENANCE.json +4 -4
- package/framework/standards/STANDARDS.json +9 -9
- 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/architecture/code-comments.md +52 -16
- package/framework/standards/backend/dotnet/testing.md +4 -2
- package/framework/standards/backend/integrations/neon-auth/neon-auth.md +63 -50
- package/framework/standards/frontend/design-system/ai-image-generation.md +42 -28
- package/framework/standards/frontend/design-system/ai-video-generation.md +24 -9
- package/framework/standards/frontend/nextjs/testing.md +4 -2
- package/framework/standards/integration/mcp/mcp-tools.md +64 -126
- 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/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/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/mcp/mcp-setup.js +37 -9
- package/src/commands/project/doctor.js +12 -6
- package/src/commands/project/init.js +6 -3
- package/src/commands/project/install-plugin-cmd.js +1 -1
- package/src/commands/project/update.js +5 -0
- package/src/commands/project/worktree.js +60 -4
- package/src/commands/state/gate-check.js +5 -0
- package/src/commands/tasks/dag.js +27 -4
- package/src/commands/validation/verify.js +22 -38
- 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 +59 -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/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 +2 -0
- package/src/dashboard/vite-plugin-morph-data.js +134 -17
- package/src/lib/detectors/claude-config-detector.js +2 -7
- package/src/lib/detectors/index.js +5 -1
- package/src/lib/evaluator-report.js +22 -0
- package/src/lib/generators/settings-generator.js +208 -209
- 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 +13 -0
- package/src/lib/tasks/tasks-json-parser.js +21 -0
- package/src/lib/validators/code-budget/code-budget-validator.js +32 -4
- package/src/lib/worktree/env-allocator.js +9 -3
- package/src/scripts/install-plugin.js +1 -1
- package/src/scripts/setup-infra.js +2 -4
- package/src/utils/agents-installer.js +108 -13
- package/src/utils/banner.js +1 -1
- 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,111 @@
|
|
|
1
|
+
import { PHASES, PHASE_INDEX, GATE_LABEL, phaseMeta } from '../lib/phases.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The phase rail: one segment per phase, a notch after each phase that ends in
|
|
5
|
+
* an approval gate. The same motif is used in the feature list (sm) and in the
|
|
6
|
+
* feature header (lg), so a feature's progress always reads the same way.
|
|
7
|
+
*
|
|
8
|
+
* Segment state is derived, never stored:
|
|
9
|
+
* done — before the current phase, or the whole feature is completed
|
|
10
|
+
* current — the derived phase of an active feature
|
|
11
|
+
* skipped — an optional phase (uiux) the feature went past without its gate
|
|
12
|
+
* todo — after the current phase
|
|
13
|
+
*/
|
|
14
|
+
export function segmentStates(feature) {
|
|
15
|
+
const idx = PHASE_INDEX[feature.phase] ?? 0;
|
|
16
|
+
const finished = feature.status === 'completed';
|
|
17
|
+
const gate = (id) => feature.gates?.find((g) => g.gate === id);
|
|
18
|
+
return PHASES.map((p, i) => {
|
|
19
|
+
let state = 'todo';
|
|
20
|
+
if (p.optional && !gate(p.id)?.approved && (i < idx || finished)) state = 'skipped';
|
|
21
|
+
else if (finished || i < idx) state = 'done';
|
|
22
|
+
else if (i === idx) state = 'current';
|
|
23
|
+
return { ...p, state, gate: gate(p.id) || null };
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function notchState(g, segState) {
|
|
28
|
+
if (!g || g.skipped || segState === 'skipped') return null;
|
|
29
|
+
if (g.approved) return 'approved';
|
|
30
|
+
if (g.paused) return 'paused';
|
|
31
|
+
return 'pending';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const NOTCH_CLASS = {
|
|
35
|
+
approved: 'bg-[var(--color-ok)] shadow-[0_0_0_3px_rgb(158_206_106_/_0.15)]',
|
|
36
|
+
paused: 'bg-transparent ring-2 ring-[var(--color-warn)] animate-pulse',
|
|
37
|
+
pending: 'bg-transparent ring-1 ring-[var(--color-hair-strong)]',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function gateTitle(g) {
|
|
41
|
+
const label = GATE_LABEL[g.gate] || g.gate;
|
|
42
|
+
if (g.approved) return `${label}: aprovado${g.approvedBy ? ` por ${g.approvedBy}` : ''}`;
|
|
43
|
+
if (g.paused) return `${label}: pausado${g.reason ? `, ${g.reason}` : ''}`;
|
|
44
|
+
return `${label}: pendente`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default function PhaseRail({ feature, size = 'sm' }) {
|
|
48
|
+
const segments = segmentStates(feature);
|
|
49
|
+
const lg = size === 'lg';
|
|
50
|
+
const current = phaseMeta(feature.phase);
|
|
51
|
+
const summary = feature.status === 'completed'
|
|
52
|
+
? 'Concluída'
|
|
53
|
+
: `Fase atual: ${current.label}${feature.inEvaluation ? ' (avaliação em andamento)' : ''}`;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className={lg ? 'w-full' : 'w-full min-w-[9rem]'} role="img" aria-label={summary}>
|
|
57
|
+
<div className="flex items-center">
|
|
58
|
+
{segments.map((s, i) => {
|
|
59
|
+
const notch = notchState(s.gate, s.state);
|
|
60
|
+
return (
|
|
61
|
+
<div key={s.id} className="flex items-center flex-1 min-w-0" title={s.label}>
|
|
62
|
+
<div
|
|
63
|
+
className={[
|
|
64
|
+
'relative flex-1 overflow-hidden rounded-full',
|
|
65
|
+
lg ? 'h-2.5' : 'h-1.5',
|
|
66
|
+
s.state === 'skipped' ? 'border border-dashed border-[var(--color-hair-strong)] bg-transparent' : '',
|
|
67
|
+
s.state === 'todo' ? 'bg-white/[0.07]' : '',
|
|
68
|
+
].join(' ')}
|
|
69
|
+
style={s.state === 'done' || s.state === 'current' ? { background: s.color, opacity: s.state === 'done' ? 0.78 : 1 } : undefined}
|
|
70
|
+
>
|
|
71
|
+
{s.state === 'current' && feature.status !== 'completed' && (
|
|
72
|
+
<span className="rail-sheen absolute inset-0" aria-hidden="true" />
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
{notch && (
|
|
76
|
+
<span
|
|
77
|
+
title={gateTitle(s.gate)}
|
|
78
|
+
className={['shrink-0 rounded-full', lg ? 'mx-1.5 h-3 w-3' : 'mx-1 h-2 w-2', NOTCH_CLASS[notch]].join(' ')}
|
|
79
|
+
/>
|
|
80
|
+
)}
|
|
81
|
+
{!notch && i < segments.length - 1 && <span className={lg ? 'w-3' : 'w-2'} />}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
</div>
|
|
86
|
+
{lg && (
|
|
87
|
+
<div className="mt-2.5 flex">
|
|
88
|
+
{segments.map((s) => (
|
|
89
|
+
<div key={s.id} className="flex-1 min-w-0 pr-3">
|
|
90
|
+
<p
|
|
91
|
+
className={[
|
|
92
|
+
'truncate text-xs',
|
|
93
|
+
s.state === 'current' ? 'font-medium text-[var(--color-fg)]' : 'text-[var(--color-dim)]',
|
|
94
|
+
s.state === 'skipped' ? 'line-through decoration-[var(--color-faint)]' : '',
|
|
95
|
+
].join(' ')}
|
|
96
|
+
>
|
|
97
|
+
{s.label}
|
|
98
|
+
</p>
|
|
99
|
+
{s.state === 'skipped' && <p className="truncate text-[11px] text-[var(--color-faint)]">fase não usada</p>}
|
|
100
|
+
{s.state !== 'skipped' && s.gate && !s.gate.skipped && (
|
|
101
|
+
<p className="truncate text-[11px] text-[var(--color-faint)]">
|
|
102
|
+
{s.gate.approved ? `aprovado${s.gate.approvedBy ? `, ${s.gate.approvedBy}` : ''}` : s.gate.paused ? 'gate pausado' : 'gate pendente'}
|
|
103
|
+
</p>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -1,22 +1,40 @@
|
|
|
1
|
-
import { useState } from 'react';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { MagnifyingGlass } from '@phosphor-icons/react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The toolbar of the knowledge pages: a search box and a few selects. The
|
|
6
|
+
* `value` of each filter is controlled by the page; the search text is local.
|
|
7
|
+
*/
|
|
8
|
+
export default function SearchFilter({ placeholder = 'Buscar', filters = [], onSearchChange, onFilterChange, children }) {
|
|
9
|
+
const [search, setSearch] = useState('');
|
|
10
|
+
function handleSearchChange(e) {
|
|
11
|
+
setSearch(e.target.value);
|
|
12
|
+
onSearchChange?.(e.target.value);
|
|
13
|
+
}
|
|
14
|
+
return (
|
|
15
|
+
<div className="relative z-10 flex flex-wrap items-center gap-2 border-b border-[var(--color-hair)] bg-[var(--color-ink)]/70 px-4 py-3 backdrop-blur md:px-6">
|
|
16
|
+
<label className="relative min-w-[12rem] flex-1">
|
|
17
|
+
<span className="sr-only">{placeholder}</span>
|
|
18
|
+
<MagnifyingGlass weight="light" size={16} className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-[var(--color-faint)]" />
|
|
19
|
+
<input
|
|
20
|
+
type="text" value={search} onChange={handleSearchChange} placeholder={placeholder}
|
|
21
|
+
onKeyDown={(e) => { if (e.key === 'Escape') handleSearchChange({ target: { value: '' } }); }}
|
|
22
|
+
className="fluid w-full rounded-full bg-white/[0.04] py-2 pl-10 pr-4 text-sm text-[var(--color-fg)] shadow-[inset_0_0_0_1px_var(--color-hair)] outline-none transition-shadow placeholder:text-[var(--color-faint)] focus:shadow-[inset_0_0_0_1px_var(--color-hair-strong)]"
|
|
23
|
+
/>
|
|
24
|
+
</label>
|
|
25
|
+
{filters.map(({ name, label, options, value }) => (
|
|
26
|
+
<label key={name} className="text-sm">
|
|
27
|
+
<span className="sr-only">{label}</span>
|
|
28
|
+
<select
|
|
29
|
+
value={value} onChange={(e) => onFilterChange?.(name, e.target.value)}
|
|
30
|
+
className="fluid rounded-full bg-white/[0.04] px-3 py-2 text-sm text-[var(--color-fg)] shadow-[inset_0_0_0_1px_var(--color-hair)] outline-none"
|
|
31
|
+
>
|
|
32
|
+
<option value="">{label}: todos</option>
|
|
33
|
+
{options.map((opt) => (<option key={opt} value={opt}>{opt}</option>))}
|
|
34
|
+
</select>
|
|
35
|
+
</label>
|
|
36
|
+
))}
|
|
37
|
+
{children}
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -1,44 +1,153 @@
|
|
|
1
|
-
import { NavLink } from 'react-router-dom';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
{ to: '/
|
|
9
|
-
{ to: '/
|
|
10
|
-
{ to: '/
|
|
1
|
+
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
|
2
|
+
import {
|
|
3
|
+
MagnifyingGlass, SquaresFour, Kanban, Pulse, BookOpen, FlowArrow, PuzzlePiece, UsersThree, Books, Ruler, Plug, Scales,
|
|
4
|
+
} from '@phosphor-icons/react';
|
|
5
|
+
import { projectHref } from '../lib/project.js';
|
|
6
|
+
|
|
7
|
+
const KNOWLEDGE = [
|
|
8
|
+
{ to: '/guide', label: 'Guia', icon: BookOpen },
|
|
9
|
+
{ to: '/phases', label: 'Fases', icon: FlowArrow },
|
|
10
|
+
{ to: '/skills', label: 'Skills', icon: PuzzlePiece },
|
|
11
|
+
{ to: '/agents', label: 'Personas', icon: UsersThree },
|
|
12
|
+
{ to: '/standards', label: 'Standards', icon: Books },
|
|
13
|
+
{ to: '/rules', label: 'Rules', icon: Ruler },
|
|
14
|
+
{ to: '/hooks', label: 'Hooks', icon: Plug },
|
|
15
|
+
{ to: '/rubrics', label: 'Rubricas', icon: Scales },
|
|
11
16
|
];
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
function Badge({ pulse }) {
|
|
19
|
+
if (!pulse) return null;
|
|
20
|
+
if (pulse.awaiting) {
|
|
21
|
+
return <span className="ml-auto rounded-full bg-[var(--color-warn)]/15 px-1.5 font-mono text-[11px] text-[var(--color-warn)]" title="features esperando você">{pulse.awaiting}</span>;
|
|
22
|
+
}
|
|
23
|
+
if (pulse.working) {
|
|
24
|
+
return <span className="ml-auto h-1.5 w-1.5 rounded-full bg-[var(--color-phase-implement)]" title="agente trabalhando agora" />;
|
|
25
|
+
}
|
|
26
|
+
return pulse.active ? <span className="ml-auto font-mono text-[11px] text-[var(--color-faint)]">{pulse.active}</span> : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const linkClass = ({ isActive }) => [
|
|
30
|
+
'fluid flex items-center gap-2.5 rounded-lg px-3 py-1.5 text-sm transition-colors',
|
|
31
|
+
isActive ? 'bg-white/[0.08] text-[var(--color-fg)] shadow-[inset_0_1px_0_rgb(255_255_255_/_0.06)]' : 'text-[var(--color-dim)] hover:bg-white/[0.04] hover:text-[var(--color-fg)]',
|
|
32
|
+
].join(' ');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Switching project keeps the page: from a feature's detail it lands on the
|
|
36
|
+
* other project's feature list, from Activity on the other project's Activity.
|
|
37
|
+
*/
|
|
38
|
+
function ProjectSwitcher({ portfolio, current }) {
|
|
39
|
+
const navigate = useNavigate();
|
|
40
|
+
const { pathname } = useLocation();
|
|
41
|
+
const onChange = (id) => {
|
|
42
|
+
const sub = /^\/p\/[^/]+\/activity/.test(pathname) ? 'activity' : 'features';
|
|
43
|
+
navigate(projectHref(id, sub));
|
|
44
|
+
};
|
|
45
|
+
return (
|
|
46
|
+
<label className="mx-1 mb-1 block">
|
|
47
|
+
<span className="sr-only">Trocar de projeto</span>
|
|
48
|
+
<select
|
|
49
|
+
value={current || ''}
|
|
50
|
+
onChange={(e) => onChange(e.target.value)}
|
|
51
|
+
className="fluid w-full truncate rounded-lg bg-white/[0.04] px-2.5 py-1.5 text-sm font-medium text-[var(--color-fg)] shadow-[inset_0_0_0_1px_var(--color-hair)] outline-none"
|
|
52
|
+
>
|
|
53
|
+
{portfolio.projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
54
|
+
</select>
|
|
55
|
+
</label>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default function Sidebar({ portfolio, current, pulse, onSearch }) {
|
|
60
|
+
const projects = portfolio?.projects || [];
|
|
61
|
+
const currentName = projects.find((p) => p.id === current)?.name;
|
|
14
62
|
return (
|
|
15
|
-
<nav className="w-
|
|
16
|
-
<div className="
|
|
17
|
-
<
|
|
18
|
-
morph-spec
|
|
19
|
-
|
|
20
|
-
|
|
63
|
+
<nav aria-label="Navegação principal" className="relative z-10 hidden w-56 shrink-0 flex-col p-3 md:flex">
|
|
64
|
+
<div className="bezel flex flex-1 flex-col">
|
|
65
|
+
<div className="bezel-core flex flex-1 flex-col overflow-y-auto px-2 py-4">
|
|
66
|
+
<p className="px-3 pb-4 text-[15px] font-semibold tracking-tight">morph<span className="text-[var(--color-faint)]">-spec</span></p>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={onSearch}
|
|
70
|
+
className="fluid mx-1 mb-5 flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm text-[var(--color-faint)] shadow-[inset_0_0_0_1px_var(--color-hair)] transition-colors hover:text-[var(--color-dim)]"
|
|
71
|
+
>
|
|
72
|
+
<MagnifyingGlass weight="light" size={15} /> Buscar
|
|
73
|
+
<kbd className="ml-auto font-mono text-[11px]">Ctrl K</kbd>
|
|
74
|
+
</button>
|
|
75
|
+
|
|
76
|
+
<ul className="mb-4">
|
|
77
|
+
<li>
|
|
78
|
+
<NavLink to="/" end className={linkClass}>
|
|
79
|
+
<SquaresFour weight="light" size={17} aria-hidden="true" /> Portfólio
|
|
80
|
+
{pulse?.awaiting > 0 && <Badge pulse={{ awaiting: pulse.awaiting }} />}
|
|
81
|
+
</NavLink>
|
|
82
|
+
</li>
|
|
83
|
+
</ul>
|
|
84
|
+
|
|
85
|
+
{current && (
|
|
86
|
+
<div className="mb-5">
|
|
87
|
+
<p className="px-3 pb-1.5 text-xs text-[var(--color-faint)]">Projeto</p>
|
|
88
|
+
{projects.length > 1
|
|
89
|
+
? <ProjectSwitcher portfolio={portfolio} current={current} />
|
|
90
|
+
: <p className="truncate px-3 pb-1 text-sm font-medium">{currentName}</p>}
|
|
91
|
+
<ul>
|
|
92
|
+
<li>
|
|
93
|
+
<NavLink to={projectHref(current)} className={linkClass}>
|
|
94
|
+
<Kanban weight="light" size={17} aria-hidden="true" /> Features
|
|
95
|
+
<Badge pulse={pulse?.byProject?.[current]} />
|
|
96
|
+
</NavLink>
|
|
97
|
+
</li>
|
|
98
|
+
<li>
|
|
99
|
+
<NavLink to={projectHref(current, 'activity')} className={linkClass}>
|
|
100
|
+
<Pulse weight="light" size={17} aria-hidden="true" /> Atividade
|
|
101
|
+
</NavLink>
|
|
102
|
+
</li>
|
|
103
|
+
</ul>
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
<div className="mb-5">
|
|
108
|
+
<p className="px-3 pb-1.5 text-xs text-[var(--color-faint)]">Como funciona</p>
|
|
109
|
+
<ul>
|
|
110
|
+
{KNOWLEDGE.map(({ to, label, icon: Icon }) => (
|
|
111
|
+
<li key={to}>
|
|
112
|
+
<NavLink to={to} className={linkClass}>
|
|
113
|
+
<Icon weight="light" size={17} aria-hidden="true" />
|
|
114
|
+
{label}
|
|
115
|
+
</NavLink>
|
|
116
|
+
</li>
|
|
117
|
+
))}
|
|
118
|
+
</ul>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
21
121
|
</div>
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
122
|
+
</nav>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The same destinations as a compact bar for narrow screens. */
|
|
127
|
+
export function MobileNav({ current, onSearch }) {
|
|
128
|
+
const items = [
|
|
129
|
+
{ to: '/', label: 'Portfólio' },
|
|
130
|
+
...(current ? [{ to: projectHref(current), label: 'Features' }, { to: projectHref(current, 'activity'), label: 'Atividade' }] : []),
|
|
131
|
+
...KNOWLEDGE,
|
|
132
|
+
];
|
|
133
|
+
return (
|
|
134
|
+
<nav aria-label="Navegação" className="sticky top-0 z-20 flex gap-1 overflow-x-auto [scrollbar-width:none] border-b border-[var(--color-hair)] bg-[var(--color-ink)]/85 px-3 py-2 backdrop-blur md:hidden">
|
|
135
|
+
<button type="button" onClick={onSearch} aria-label="Buscar" className="shrink-0 rounded-full px-2.5 py-1 text-[var(--color-dim)]">
|
|
136
|
+
<MagnifyingGlass weight="light" size={16} />
|
|
137
|
+
</button>
|
|
138
|
+
{items.map(({ to, label }) => (
|
|
139
|
+
<NavLink
|
|
140
|
+
key={to}
|
|
141
|
+
to={to}
|
|
142
|
+
end={to === '/'}
|
|
143
|
+
className={({ isActive }) => [
|
|
144
|
+
'shrink-0 rounded-full px-3 py-1 text-sm',
|
|
145
|
+
isActive ? 'bg-white/[0.1] text-[var(--color-fg)]' : 'text-[var(--color-dim)]',
|
|
146
|
+
].join(' ')}
|
|
147
|
+
>
|
|
148
|
+
{label}
|
|
149
|
+
</NavLink>
|
|
150
|
+
))}
|
|
42
151
|
</nav>
|
|
43
152
|
);
|
|
44
153
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fetch a `/api/morph/*` resource and refetch it whenever the dev server
|
|
5
|
+
* reports that the project's `.morph/` changed in the given scope.
|
|
6
|
+
*
|
|
7
|
+
* `scope` matches the server's invalidation: 'features' (feature.json,
|
|
8
|
+
* tasks.json, artifacts) or 'activity' (events.jsonl). A refetch keeps the old
|
|
9
|
+
* data on screen until the new one arrives — no flash of "loading" while an
|
|
10
|
+
* agent is working.
|
|
11
|
+
*
|
|
12
|
+
* @param {string|null} url - null skips the request
|
|
13
|
+
* @param {{ scope?: string|string[], project?: string }} [opts] - `scope`: 'features',
|
|
14
|
+
* 'activity' or 'projects'; `project`: only changes of this project refetch
|
|
15
|
+
* @returns {{ data: any, error: string|null, loading: boolean, refreshedAt: number|null, refetch: () => void }}
|
|
16
|
+
*/
|
|
17
|
+
export function useMorphQuery(url, { scope = 'features', project } = {}) {
|
|
18
|
+
const [state, setState] = useState({ data: null, error: null, loading: Boolean(url), refreshedAt: null });
|
|
19
|
+
const [tick, setTick] = useState(0);
|
|
20
|
+
const urlRef = useRef(url);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (urlRef.current !== url) {
|
|
24
|
+
urlRef.current = url;
|
|
25
|
+
setState({ data: null, error: null, loading: Boolean(url), refreshedAt: null });
|
|
26
|
+
}
|
|
27
|
+
if (!url) return undefined;
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
fetch(url)
|
|
30
|
+
.then(async (res) => {
|
|
31
|
+
const body = await res.json().catch(() => ({}));
|
|
32
|
+
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
|
|
33
|
+
return body;
|
|
34
|
+
})
|
|
35
|
+
.then((data) => { if (!cancelled) setState({ data, error: null, loading: false, refreshedAt: Date.now() }); })
|
|
36
|
+
.catch((err) => { if (!cancelled) setState((s) => ({ ...s, error: err.message, loading: false })); });
|
|
37
|
+
return () => { cancelled = true; };
|
|
38
|
+
}, [url, tick]);
|
|
39
|
+
|
|
40
|
+
const scopes = Array.isArray(scope) ? scope.join(',') : scope;
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
const hot = import.meta.hot;
|
|
43
|
+
if (!hot) return undefined;
|
|
44
|
+
const wanted = scopes.split(',');
|
|
45
|
+
const onChange = (payload) => {
|
|
46
|
+
if (payload?.project && project && payload.project !== project) return;
|
|
47
|
+
if (!payload?.scope || wanted.includes(payload.scope)) setTick((t) => t + 1);
|
|
48
|
+
};
|
|
49
|
+
hot.on('morph:changed', onChange);
|
|
50
|
+
return () => hot.off?.('morph:changed', onChange);
|
|
51
|
+
}, [scopes, project]);
|
|
52
|
+
|
|
53
|
+
return { ...state, refetch: () => setTick((t) => t + 1) };
|
|
54
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
const PREFIX = 'morph-dashboard:';
|
|
4
|
+
|
|
5
|
+
function read(key, fallback) {
|
|
6
|
+
try {
|
|
7
|
+
const raw = window.localStorage.getItem(PREFIX + key);
|
|
8
|
+
return raw === null ? fallback : JSON.parse(raw);
|
|
9
|
+
} catch {
|
|
10
|
+
return fallback;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A per-browser preference (view mode, filters). Storage can be unavailable or
|
|
16
|
+
* throw (private mode, SSR); the value then simply does not persist.
|
|
17
|
+
*/
|
|
18
|
+
export function usePref(key, fallback) {
|
|
19
|
+
const [value, setValue] = useState(() => (typeof window === 'undefined' ? fallback : read(key, fallback)));
|
|
20
|
+
const set = useCallback((next) => {
|
|
21
|
+
setValue((prev) => {
|
|
22
|
+
const v = typeof next === 'function' ? next(prev) : next;
|
|
23
|
+
try { window.localStorage.setItem(PREFIX + key, JSON.stringify(v)); } catch { /* not persisted */ }
|
|
24
|
+
return v;
|
|
25
|
+
});
|
|
26
|
+
}, [key]);
|
|
27
|
+
return [value, set];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Re-render every `ms` so relative times ("há 30 s") and "working now" stay true. */
|
|
31
|
+
export function useNow(ms = 15_000) {
|
|
32
|
+
const [now, setNow] = useState(() => Date.now());
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const id = setInterval(() => setNow(Date.now()), ms);
|
|
35
|
+
return () => clearInterval(id);
|
|
36
|
+
}, [ms]);
|
|
37
|
+
return now;
|
|
38
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const rtf = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' });
|
|
2
|
+
const UNITS = [
|
|
3
|
+
['year', 365 * 24 * 3600],
|
|
4
|
+
['month', 30 * 24 * 3600],
|
|
5
|
+
['week', 7 * 24 * 3600],
|
|
6
|
+
['day', 24 * 3600],
|
|
7
|
+
['hour', 3600],
|
|
8
|
+
['minute', 60],
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
/** "há 12 minutos" — or an em dash when there is no date. */
|
|
12
|
+
export function timeAgo(iso, now = Date.now()) {
|
|
13
|
+
const t = Date.parse(iso || '');
|
|
14
|
+
if (Number.isNaN(t)) return '—';
|
|
15
|
+
const diff = (t - now) / 1000;
|
|
16
|
+
for (const [unit, secs] of UNITS) {
|
|
17
|
+
if (Math.abs(diff) >= secs) return rtf.format(Math.round(diff / secs), unit);
|
|
18
|
+
}
|
|
19
|
+
return 'agora';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function dateTime(iso) {
|
|
23
|
+
const t = Date.parse(iso || '');
|
|
24
|
+
if (Number.isNaN(t)) return '—';
|
|
25
|
+
return new Date(t).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function clock(iso) {
|
|
29
|
+
const t = Date.parse(iso || '');
|
|
30
|
+
if (Number.isNaN(t)) return '—';
|
|
31
|
+
return new Date(t).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function dayLabel(iso) {
|
|
35
|
+
const t = Date.parse(iso || '');
|
|
36
|
+
if (Number.isNaN(t)) return '—';
|
|
37
|
+
return new Date(t).toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const compact = new Intl.NumberFormat('pt-BR', { notation: 'compact', maximumFractionDigits: 1 });
|
|
41
|
+
export function num(n) {
|
|
42
|
+
return typeof n === 'number' && Number.isFinite(n) ? compact.format(n) : '—';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function bytes(n) {
|
|
46
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) return '—';
|
|
47
|
+
if (n < 1024) return `${n} B`;
|
|
48
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
49
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function duration(seconds) {
|
|
53
|
+
if (typeof seconds !== 'number' || !Number.isFinite(seconds) || seconds <= 0) return '—';
|
|
54
|
+
const m = Math.round(seconds / 60);
|
|
55
|
+
if (m < 60) return `${m} min`;
|
|
56
|
+
return `${Math.floor(m / 60)} h ${m % 60} min`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Strip a leading YAML frontmatter block, returning both parts. */
|
|
60
|
+
export function splitFrontmatter(text) {
|
|
61
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(text || '');
|
|
62
|
+
if (!m) return { meta: null, body: text || '' };
|
|
63
|
+
const meta = {};
|
|
64
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
65
|
+
const kv = /^([\w-]+):\s*(.*)$/.exec(line);
|
|
66
|
+
if (kv) meta[kv[1]] = kv[2].replace(/^["']|["']$/g, '');
|
|
67
|
+
}
|
|
68
|
+
return { meta, body: text.slice(m[0].length) };
|
|
69
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display vocabulary for the phase pipeline. The ORDER and the optional flag
|
|
3
|
+
* come from framework/phases.json (morphData.phases); this file only adds the
|
|
4
|
+
* words and the colour token each phase is shown with.
|
|
5
|
+
*/
|
|
6
|
+
import morphData from 'virtual:morph-data';
|
|
7
|
+
|
|
8
|
+
const LABELS = {
|
|
9
|
+
proposal: 'Proposta',
|
|
10
|
+
uiux: 'UI/UX',
|
|
11
|
+
plan: 'Plano',
|
|
12
|
+
implement: 'Implementação',
|
|
13
|
+
review: 'Revisão',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const PHASES = (morphData.phases?.list || []).map((p) => ({
|
|
17
|
+
id: p.id,
|
|
18
|
+
label: LABELS[p.id] || p.displayName || p.id,
|
|
19
|
+
optional: Boolean(p.optional),
|
|
20
|
+
folder: p.folder || null,
|
|
21
|
+
skill: p.skill || null,
|
|
22
|
+
color: `var(--color-phase-${p.id})`,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
export const PHASE_INDEX = Object.fromEntries(PHASES.map((p, i) => [p.id, i]));
|
|
26
|
+
|
|
27
|
+
export function phaseMeta(id) {
|
|
28
|
+
return PHASES.find((p) => p.id === id) || { id, label: LABELS[id] || id || '—', color: 'var(--color-faint)', optional: false };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const GATE_LABEL = {
|
|
32
|
+
proposal: 'Gate 1 (proposta)',
|
|
33
|
+
uiux: 'Gate UI/UX',
|
|
34
|
+
plan: 'Gate 2 (plano)',
|
|
35
|
+
review: 'Gate 3 (revisão)',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const STATUS_LABEL = {
|
|
39
|
+
draft: 'Rascunho',
|
|
40
|
+
in_progress: 'Em andamento',
|
|
41
|
+
completed: 'Concluída',
|
|
42
|
+
abandoned: 'Abandonada',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const WORKTYPE_LABEL = {
|
|
46
|
+
feature: 'feature',
|
|
47
|
+
bug: 'bug',
|
|
48
|
+
chore: 'chore',
|
|
49
|
+
hotfix: 'hotfix',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const LOCATION_LABEL = {
|
|
53
|
+
root: 'raiz',
|
|
54
|
+
worktree: 'worktree',
|
|
55
|
+
archive: 'arquivo',
|
|
56
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { useLocation } from 'react-router-dom';
|
|
3
|
+
import { usePref } from '../hooks/usePref.js';
|
|
4
|
+
import { useMorphQuery } from '../hooks/useMorphQuery.js';
|
|
5
|
+
|
|
6
|
+
/** The live API of one project. */
|
|
7
|
+
export const apiBase = (id) => `/api/morph/p/${encodeURIComponent(id)}`;
|
|
8
|
+
|
|
9
|
+
/** A page of one project: `/p/{id}/features`, `/p/{id}/features/{name}`, `/p/{id}/activity`. */
|
|
10
|
+
export const projectHref = (id, sub = 'features') => `/p/${encodeURIComponent(id)}/${sub}`;
|
|
11
|
+
|
|
12
|
+
export const featureHref = (project, name, query = '') =>
|
|
13
|
+
projectHref(project, `features/${encodeURIComponent(name)}`) + query;
|
|
14
|
+
|
|
15
|
+
const PROJECTS_CHANGED = 'morph:projects-changed';
|
|
16
|
+
|
|
17
|
+
/** Tell every reader of the portfolio (page, sidebar, search) to read it again. */
|
|
18
|
+
export const announceProjectsChanged = () => window.dispatchEvent(new Event(PROJECTS_CHANGED));
|
|
19
|
+
|
|
20
|
+
/** The portfolio: registered projects, their summaries, and the ones found next to them. */
|
|
21
|
+
export function useProjects() {
|
|
22
|
+
const q = useMorphQuery('/api/morph/projects', { scope: 'projects' });
|
|
23
|
+
const refetch = useRef(q.refetch);
|
|
24
|
+
refetch.current = q.refetch;
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const on = () => refetch.current();
|
|
27
|
+
window.addEventListener(PROJECTS_CHANGED, on);
|
|
28
|
+
return () => window.removeEventListener(PROJECTS_CHANGED, on);
|
|
29
|
+
}, []);
|
|
30
|
+
return q;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The project the person is looking at: the one in the URL, else the last one
|
|
35
|
+
* they opened, else the one the dashboard was launched from. Knowledge pages
|
|
36
|
+
* have no project in the URL but the sidebar still needs one to link to.
|
|
37
|
+
*
|
|
38
|
+
* @param {{ launch?: string|null, projects?: Array<{id: string}> }|null} portfolio
|
|
39
|
+
* @returns {{ id: string|null, inUrl: boolean }}
|
|
40
|
+
*/
|
|
41
|
+
export function useCurrentProject(portfolio) {
|
|
42
|
+
const { pathname } = useLocation();
|
|
43
|
+
const m = /^\/p\/([^/]+)/.exec(pathname);
|
|
44
|
+
const fromUrl = m ? decodeURIComponent(m[1]) : null;
|
|
45
|
+
const [last, setLast] = usePref('project.last', null);
|
|
46
|
+
useEffect(() => { if (fromUrl && fromUrl !== last) setLast(fromUrl); }, [fromUrl, last, setLast]);
|
|
47
|
+
const known = (id) => id && (!portfolio?.projects || portfolio.projects.some((p) => p.id === id));
|
|
48
|
+
const id = fromUrl || (known(last) ? last : null) || portfolio?.launch || null;
|
|
49
|
+
return { id, inUrl: Boolean(fromUrl) };
|
|
50
|
+
}
|