@sugar-crash-studios/vibe-forge 0.4.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/commands/clear-attention.md +63 -0
- package/.claude/commands/compact-context.md +52 -0
- package/.claude/commands/configure-vcs.md +102 -0
- package/.claude/commands/forge.md +171 -0
- package/.claude/commands/need-help.md +77 -0
- package/.claude/commands/update-status.md +64 -0
- package/.claude/commands/worker-loop.md +106 -0
- package/.claude/hooks/worker-loop.js +198 -0
- package/.claude/scripts/setup-worker-loop.sh +45 -0
- package/.claude/settings.local.json +46 -0
- package/LICENSE +21 -0
- package/README.md +238 -0
- package/agents/aegis/personality.md +294 -0
- package/agents/anvil/personality.md +276 -0
- package/agents/architect/personality.md +258 -0
- package/agents/crucible/personality.md +360 -0
- package/agents/ember/personality.md +291 -0
- package/agents/forge-master/capabilities.md +144 -0
- package/agents/forge-master/context-template.md +128 -0
- package/agents/forge-master/personality.md +138 -0
- package/agents/furnace/personality.md +340 -0
- package/agents/herald/personality.md +247 -0
- package/agents/loki/personality.md +108 -0
- package/agents/oracle/personality.md +283 -0
- package/agents/pixel/personality.md +113 -0
- package/agents/planning-hub/personality.md +320 -0
- package/agents/scribe/personality.md +251 -0
- package/agents/temper/personality.md +218 -0
- package/bin/cli.js +375 -0
- package/bin/dashboard/api/agents.js +333 -0
- package/bin/dashboard/api/dispatch.js +483 -0
- package/bin/dashboard/api/tasks.js +416 -0
- package/bin/dashboard/frontend/index.html +13 -0
- package/bin/dashboard/frontend/package.json +16 -0
- package/bin/dashboard/frontend/src/App.svelte +222 -0
- package/bin/dashboard/frontend/src/app.css +1777 -0
- package/bin/dashboard/frontend/src/lib/components/AgentCard.svelte +60 -0
- package/bin/dashboard/frontend/src/lib/components/AgentsPanel.svelte +57 -0
- package/bin/dashboard/frontend/src/lib/components/DispatchModal.svelte +180 -0
- package/bin/dashboard/frontend/src/lib/components/Footer.svelte +33 -0
- package/bin/dashboard/frontend/src/lib/components/Header.svelte +84 -0
- package/bin/dashboard/frontend/src/lib/components/IssueCard.svelte +33 -0
- package/bin/dashboard/frontend/src/lib/components/IssuesPanel.svelte +73 -0
- package/bin/dashboard/frontend/src/lib/components/KeyboardShortcutsModal.svelte +108 -0
- package/bin/dashboard/frontend/src/lib/components/MobileTabs.svelte +52 -0
- package/bin/dashboard/frontend/src/lib/components/NotificationCard.svelte +60 -0
- package/bin/dashboard/frontend/src/lib/components/NotificationsPanel.svelte +44 -0
- package/bin/dashboard/frontend/src/lib/components/TaskCard.svelte +63 -0
- package/bin/dashboard/frontend/src/lib/components/TasksPanel.svelte +82 -0
- package/bin/dashboard/frontend/src/lib/components/Toast.svelte +45 -0
- package/bin/dashboard/frontend/src/lib/stores/agents.js +34 -0
- package/bin/dashboard/frontend/src/lib/stores/issues.js +54 -0
- package/bin/dashboard/frontend/src/lib/stores/notifications.js +48 -0
- package/bin/dashboard/frontend/src/lib/stores/tasks.js +63 -0
- package/bin/dashboard/frontend/src/lib/stores/theme.js +33 -0
- package/bin/dashboard/frontend/src/lib/stores/toast.js +35 -0
- package/bin/dashboard/frontend/src/lib/stores/ui.js +25 -0
- package/bin/dashboard/frontend/src/lib/stores/voice.js +275 -0
- package/bin/dashboard/frontend/src/lib/stores/websocket.js +295 -0
- package/bin/dashboard/frontend/src/lib/utils/api.js +101 -0
- package/bin/dashboard/frontend/src/lib/utils/formatters.js +54 -0
- package/bin/dashboard/frontend/src/main.js +9 -0
- package/bin/dashboard/frontend/svelte.config.js +5 -0
- package/bin/dashboard/frontend/vite.config.js +20 -0
- package/bin/dashboard/public/assets/index-DnfVj9Ce.css +1 -0
- package/bin/dashboard/public/assets/index-Ze5h0kXQ.js +2 -0
- package/bin/dashboard/public/index.html +14 -0
- package/bin/dashboard/server.js +566 -0
- package/bin/forge-daemon.sh +463 -0
- package/bin/forge-setup.sh +645 -0
- package/bin/forge-spawn.sh +164 -0
- package/bin/forge.cmd +83 -0
- package/bin/forge.sh +533 -0
- package/bin/lib/agents.sh +177 -0
- package/bin/lib/colors.sh +44 -0
- package/bin/lib/config.sh +347 -0
- package/bin/lib/constants.sh +241 -0
- package/bin/lib/daemon/display.sh +128 -0
- package/bin/lib/daemon/notifications.sh +263 -0
- package/bin/lib/daemon/routing.sh +77 -0
- package/bin/lib/daemon/state.sh +115 -0
- package/bin/lib/daemon/sync.sh +95 -0
- package/bin/lib/database.sh +310 -0
- package/bin/lib/heimdall-setup.js +113 -0
- package/bin/lib/heimdall.js +265 -0
- package/bin/lib/json.sh +264 -0
- package/bin/lib/terminal.js +451 -0
- package/bin/lib/util.sh +126 -0
- package/bin/lib/vcs.js +349 -0
- package/config/agent-manifest.yaml +203 -0
- package/config/agents.json +168 -0
- package/config/task-template.md +159 -0
- package/config/task-types.yaml +106 -0
- package/context/agent-status/aegis.json +7 -0
- package/context/agent-status/anvil.json +7 -0
- package/context/agent-status/architect.json +7 -0
- package/context/agent-status/crucible.json +7 -0
- package/context/agent-status/ember.json +7 -0
- package/context/agent-status/furnace.json +7 -0
- package/context/agent-status/loki.json +7 -0
- package/context/agent-status/oracle.json +7 -0
- package/context/agent-status/pixel.json +7 -0
- package/context/agent-status/planning-hub.json +7 -0
- package/context/agent-status/scribe.json +7 -0
- package/context/agent-status/temper.json +7 -0
- package/context/feature-brainstorm.md +426 -0
- package/context/forge-state.yaml +19 -0
- package/context/modern-conventions.md +129 -0
- package/context/project-context-template.md +122 -0
- package/context/project-context.md +122 -0
- package/docs/TODO.md +150 -0
- package/docs/agents.md +409 -0
- package/docs/architecture/decisions/ADR-001-daemon-modularization.md +122 -0
- package/docs/architecture/vibe-lab-integration.md +684 -0
- package/docs/architecture.md +194 -0
- package/docs/bmad-gap-analysis-2026-03-31.md +444 -0
- package/docs/cleanup-workflow.md +329 -0
- package/docs/commands.md +451 -0
- package/docs/dashboard-mockup.html +989 -0
- package/docs/getting-started.md +261 -0
- package/docs/integration/forge-ownership-policy.md +112 -0
- package/docs/npm-publishing.md +132 -0
- package/docs/roadmap-2026.md +519 -0
- package/docs/security.md +144 -0
- package/docs/wireframes/dashboard-mvp.md +1164 -0
- package/docs/workflows/README.md +32 -0
- package/docs/workflows/azure-devops.md +108 -0
- package/docs/workflows/bitbucket.md +104 -0
- package/docs/workflows/git-only.md +130 -0
- package/docs/workflows/gitea.md +168 -0
- package/docs/workflows/github.md +103 -0
- package/docs/workflows/gitlab.md +105 -0
- package/docs/workflows.md +454 -0
- package/package.json +73 -0
- package/tasks/completed/ARCH-001-duplicate-agent-config.md +121 -0
- package/tasks/completed/ARCH-002-mixed-bash-node-implementation.md +88 -0
- package/tasks/completed/ARCH-003-worker-loop-hook-duplication.md +77 -0
- package/tasks/completed/ARCH-009-test-organization.md +78 -0
- package/tasks/completed/ARCH-011-jq-vs-nodejs-json.md +94 -0
- package/tasks/completed/ARCH-012-tmp-files-in-root.md +71 -0
- package/tasks/completed/ARCH-013-exit-code-constants.md +65 -0
- package/tasks/completed/ARCH-014-sed-incompatibility.md +96 -0
- package/tasks/completed/ARCH-015-docs-todo-tracking.md +83 -0
- package/tasks/completed/BUG-dash-001-tasks-filter-error.md +31 -0
- package/tasks/completed/BUG-dash-002-agents-unknown.md +41 -0
- package/tasks/completed/CLEAN-001.md +38 -0
- package/tasks/completed/CLEAN-002.md +43 -0
- package/tasks/completed/CLEAN-003.md +47 -0
- package/tasks/completed/CLEAN-004.md +56 -0
- package/tasks/completed/CLEAN-005.md +75 -0
- package/tasks/completed/CLEAN-006.md +47 -0
- package/tasks/completed/CLEAN-007.md +34 -0
- package/tasks/completed/CLEAN-008.md +49 -0
- package/tasks/completed/CLEAN-012.md +58 -0
- package/tasks/completed/CLEAN-013.md +45 -0
- package/tasks/completed/FEATURE-001a-dashboard-wireframes.md +162 -0
- package/tasks/completed/IMPL-007a-daemon-notifications-module.md +82 -0
- package/tasks/completed/IMPL-007b-daemon-sync-module.md +71 -0
- package/tasks/completed/IMPL-007c-daemon-state-module.md +80 -0
- package/tasks/completed/IMPL-007d-daemon-routing-module.md +77 -0
- package/tasks/completed/IMPL-007e-daemon-display-module.md +77 -0
- package/tasks/completed/IMPL-007f-daemon-integration.md +124 -0
- package/tasks/completed/PLAT-1-heimdall.md +420 -0
- package/tasks/completed/SEC-001-sql-injection-fix.md +58 -0
- package/tasks/completed/SEC-002-notification-injection-fix.md +45 -0
- package/tasks/completed/SEC-003-eval-injection-fix.md +54 -0
- package/tasks/completed/SEC-004-pid-race-condition-fix.md +49 -0
- package/tasks/completed/SEC-005-worker-loop-path-fix.md +51 -0
- package/tasks/completed/SEC-006-eval-agent-names.md +55 -0
- package/tasks/completed/SEC-007-spawn-escaping.md +67 -0
- package/tasks/completed/TASK-DASH-001-server-infrastructure.md +185 -0
- package/tasks/completed/TASK-anvil-001-dashboard-frontend.md +133 -0
- package/tasks/completed/review-bmad-aegis.md +89 -0
- package/tasks/completed/review-bmad-anvil.md +80 -0
- package/tasks/completed/review-bmad-crucible.md +81 -0
- package/tasks/completed/review-bmad-ember.md +90 -0
- package/tasks/completed/review-bmad-furnace.md +79 -0
- package/tasks/completed/review-bmad-pixel.md +82 -0
- package/tasks/completed/review-bmad-scribe.md +92 -0
- package/tasks/completed/review-bmad-sentinel.md +83 -0
- package/tasks/pending/ARCH-004-git-bash-detection-duplication.md +72 -0
- package/tasks/pending/ARCH-005-missing-src-directory.md +95 -0
- package/tasks/pending/ARCH-006-task-template-location.md +64 -0
- package/tasks/pending/ARCH-008-forge-master-vs-hub.md +81 -0
- package/tasks/pending/ARCH-010-missing-index-files.md +84 -0
- package/tasks/pending/CLEAN-009.md +31 -0
- package/tasks/pending/CLEAN-010.md +30 -0
- package/tasks/pending/CLEAN-011.md +30 -0
- package/tasks/pending/CLEAN-014.md +32 -0
- package/tasks/pending/DESIGN-dash-001-layout-review.md +45 -0
- package/tasks/pending/FEATURE-001-dashboard-mvp.md +268 -0
- package/tasks/review/ARCH-007-daemon-monolith.md +162 -0
- package/tasks/review/bmad-review-aegis.md +349 -0
- package/tasks/review/bmad-review-anvil.md +259 -0
- package/tasks/review/bmad-review-crucible.md +277 -0
- package/tasks/review/bmad-review-ember.md +307 -0
- package/tasks/review/bmad-review-furnace.md +285 -0
- package/tasks/review/bmad-review-pixel.md +329 -0
- package/tasks/review/bmad-review-scribe.md +361 -0
- package/tasks/review/bmad-review-sentinel.md +242 -0
- package/tasks/review/task-001.md +78 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { getAgentConfig } from '../stores/agents.js';
|
|
3
|
+
import { speak } from '../stores/voice.js';
|
|
4
|
+
import { capitalize } from '../utils/formatters.js';
|
|
5
|
+
|
|
6
|
+
export let agent;
|
|
7
|
+
|
|
8
|
+
// API returns agents with field 'agent' (not 'name' or 'id')
|
|
9
|
+
$: config = getAgentConfig(agent.agent || agent.name || agent.id);
|
|
10
|
+
$: status = (agent.status || 'offline').toLowerCase();
|
|
11
|
+
$: statusText = capitalize(status);
|
|
12
|
+
|
|
13
|
+
const GREETINGS = {
|
|
14
|
+
'planning-hub': "Planning Hub online. The forge council is assembled — what needs building today?",
|
|
15
|
+
'oracle': "Oracle here. Before we build anything, let's make sure we're solving the right problem.",
|
|
16
|
+
'architect': "Architect online. Systems designed, patterns ready — what are we building?",
|
|
17
|
+
'aegis': "Aegis is here. What needs securing?",
|
|
18
|
+
'pixel': "Pixel reporting in. Let's make something people actually enjoy using.",
|
|
19
|
+
'ember': "Ember online. Infrastructure's ready — what needs shipping?",
|
|
20
|
+
'anvil': "Anvil here. Give me a component and I'll build it.",
|
|
21
|
+
'furnace': "Furnace is online. Backend's fired up — what are we building?",
|
|
22
|
+
'crucible': "Crucible here. I'll find the bugs before your users do. What are we testing?",
|
|
23
|
+
'temper': "Temper here. Send the PR when it's ready — I'll review it.",
|
|
24
|
+
'scribe': "Scribe online. What needs documenting?",
|
|
25
|
+
'herald': "Herald here. Ready to cut a release — what are we shipping?",
|
|
26
|
+
'forge-master': "Forge Master online. All systems go — what's on the anvil?",
|
|
27
|
+
'loki': "Loki here. So... are you sure you're solving the right problem?",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function handleClick() {
|
|
31
|
+
const agentKey = (agent.agent || agent.name || agent.id || '').toLowerCase();
|
|
32
|
+
const greeting = GREETINGS[agentKey] || `${agent.displayName || agentKey} online.`;
|
|
33
|
+
speak(greeting, agentKey, true);
|
|
34
|
+
}
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
38
|
+
<div class="card agent-card" tabindex="0"
|
|
39
|
+
onclick={handleClick}
|
|
40
|
+
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && handleClick()}
|
|
41
|
+
role="button"
|
|
42
|
+
aria-label="Hear {agent.displayName || agent.agent} greeting"
|
|
43
|
+
>
|
|
44
|
+
<div class="agent-avatar {config?.color || ''}">{agent.icon || config?.icon || '?'}</div>
|
|
45
|
+
<div class="agent-info">
|
|
46
|
+
<div class="agent-name">{agent.displayName || config?.name || agent.name || agent.id || 'Unknown'}</div>
|
|
47
|
+
<div class="agent-status">
|
|
48
|
+
<span class="status-indicator {status}"></span>
|
|
49
|
+
<span>{statusText}</span>
|
|
50
|
+
</div>
|
|
51
|
+
{#if agent.task}
|
|
52
|
+
<div class="agent-task">{agent.task}</div>
|
|
53
|
+
{/if}
|
|
54
|
+
{#if agent.progress !== undefined}
|
|
55
|
+
<div class="agent-progress">
|
|
56
|
+
<div class="agent-progress-bar" style="width: {agent.progress}%"></div>
|
|
57
|
+
</div>
|
|
58
|
+
{/if}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { agents, agentsLoading, agentsError } from '../stores/agents.js';
|
|
3
|
+
import { refreshAgents } from '../stores/websocket.js';
|
|
4
|
+
import AgentCard from './AgentCard.svelte';
|
|
5
|
+
|
|
6
|
+
let refreshing = false;
|
|
7
|
+
|
|
8
|
+
async function handleRefresh() {
|
|
9
|
+
refreshing = true;
|
|
10
|
+
await refreshAgents();
|
|
11
|
+
refreshing = false;
|
|
12
|
+
}
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<section id="agents-panel" class="panel agents-panel" aria-labelledby="agents-heading" tabindex="-1">
|
|
16
|
+
<div class="panel-header">
|
|
17
|
+
<h2 id="agents-heading" class="panel-title">AGENTS</h2>
|
|
18
|
+
<button
|
|
19
|
+
class="panel-refresh"
|
|
20
|
+
class:loading={refreshing}
|
|
21
|
+
onclick={handleRefresh}
|
|
22
|
+
aria-label="Refresh agents"
|
|
23
|
+
>
|
|
24
|
+
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
25
|
+
<path d="M23 4v6h-6M1 20v-6h6"/>
|
|
26
|
+
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
|
27
|
+
</svg>
|
|
28
|
+
</button>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
{#if $agentsLoading}
|
|
32
|
+
<div class="loading-state">
|
|
33
|
+
<div class="skeleton-card"></div>
|
|
34
|
+
<div class="skeleton-card"></div>
|
|
35
|
+
<div class="skeleton-card"></div>
|
|
36
|
+
</div>
|
|
37
|
+
{:else if $agentsError}
|
|
38
|
+
<div class="error-state">
|
|
39
|
+
<div class="error-icon" aria-hidden="true">!</div>
|
|
40
|
+
<p class="error-title">Failed to load agents</p>
|
|
41
|
+
<p class="error-message">{$agentsError}</p>
|
|
42
|
+
<button class="btn btn-secondary" onclick={handleRefresh}>Retry</button>
|
|
43
|
+
</div>
|
|
44
|
+
{:else if $agents.length === 0}
|
|
45
|
+
<div class="empty-state">
|
|
46
|
+
<div class="empty-icon" aria-hidden="true">☽</div>
|
|
47
|
+
<p class="empty-title">All agents idle</p>
|
|
48
|
+
<p class="empty-subtitle">Ready when you are</p>
|
|
49
|
+
</div>
|
|
50
|
+
{:else}
|
|
51
|
+
<div id="agents-list" class="panel-content agents-list" aria-busy="false">
|
|
52
|
+
{#each $agents as agent (agent.id || agent.name)}
|
|
53
|
+
<AgentCard {agent} />
|
|
54
|
+
{/each}
|
|
55
|
+
</div>
|
|
56
|
+
{/if}
|
|
57
|
+
</section>
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { pendingDispatch, dispatchLoading, dispatchError, closeDispatchModal, generateTaskId, removeIssue, getCategoryConfig } from '../stores/issues.js';
|
|
3
|
+
import { getAgentConfig } from '../stores/agents.js';
|
|
4
|
+
import { showToast } from '../stores/toast.js';
|
|
5
|
+
import { refreshTasks } from '../stores/websocket.js';
|
|
6
|
+
import { dispatchTask } from '../utils/api.js';
|
|
7
|
+
import { capitalize } from '../utils/formatters.js';
|
|
8
|
+
import { onMount, onDestroy } from 'svelte';
|
|
9
|
+
|
|
10
|
+
let modalElement;
|
|
11
|
+
let previousFocus = null;
|
|
12
|
+
|
|
13
|
+
$: issue = $pendingDispatch;
|
|
14
|
+
$: agent = issue ? getAgentConfig(issue.agent) : null;
|
|
15
|
+
$: category = issue ? getCategoryConfig(issue.category) : null;
|
|
16
|
+
$: taskId = issue ? generateTaskId() : '';
|
|
17
|
+
$: taskTitle = issue ? `Fix: ${issue.title}` : '';
|
|
18
|
+
|
|
19
|
+
async function handleConfirm() {
|
|
20
|
+
if (!issue) return;
|
|
21
|
+
|
|
22
|
+
dispatchLoading.set(true);
|
|
23
|
+
dispatchError.set(null);
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const result = await dispatchTask(issue);
|
|
27
|
+
|
|
28
|
+
// Save references before closing modal
|
|
29
|
+
const dispatchedIssueId = issue.id;
|
|
30
|
+
const dispatchedAgent = issue.agent;
|
|
31
|
+
|
|
32
|
+
// Success - close modal
|
|
33
|
+
closeDispatchModal();
|
|
34
|
+
|
|
35
|
+
// Remove issue from list
|
|
36
|
+
removeIssue(dispatchedIssueId);
|
|
37
|
+
|
|
38
|
+
// Show success toast
|
|
39
|
+
showToast({
|
|
40
|
+
type: 'success',
|
|
41
|
+
title: 'Task dispatched!',
|
|
42
|
+
message: `${result.taskId || 'Task'} assigned to ${getAgentConfig(dispatchedAgent)?.name || 'agent'}`,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Refresh tasks
|
|
46
|
+
refreshTasks();
|
|
47
|
+
} catch (error) {
|
|
48
|
+
dispatchLoading.set(false);
|
|
49
|
+
dispatchError.set('Failed to dispatch task. Please try again.');
|
|
50
|
+
console.error('Dispatch error:', error);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handleBackdropClick() {
|
|
55
|
+
closeDispatchModal();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function handleKeydown(event) {
|
|
59
|
+
if (event.key === 'Escape') {
|
|
60
|
+
closeDispatchModal();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Focus trap
|
|
64
|
+
if (event.key === 'Tab' && modalElement) {
|
|
65
|
+
const focusableElements = modalElement.querySelectorAll(
|
|
66
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (focusableElements.length === 0) {
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const firstFocusable = focusableElements[0];
|
|
75
|
+
const lastFocusable = focusableElements[focusableElements.length - 1];
|
|
76
|
+
|
|
77
|
+
if (event.shiftKey) {
|
|
78
|
+
if (document.activeElement === firstFocusable) {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
lastFocusable.focus();
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
if (document.activeElement === lastFocusable) {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
firstFocusable.focus();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Focus management
|
|
92
|
+
$: if (issue && modalElement) {
|
|
93
|
+
previousFocus = document.activeElement;
|
|
94
|
+
setTimeout(() => {
|
|
95
|
+
const cancelBtn = modalElement?.querySelector('.btn-secondary');
|
|
96
|
+
cancelBtn?.focus();
|
|
97
|
+
}, 100);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
onDestroy(() => {
|
|
101
|
+
if (previousFocus) {
|
|
102
|
+
previousFocus.focus();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
</script>
|
|
106
|
+
|
|
107
|
+
{#if issue}
|
|
108
|
+
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
109
|
+
<div
|
|
110
|
+
class="modal"
|
|
111
|
+
role="dialog"
|
|
112
|
+
aria-labelledby="dispatch-modal-title"
|
|
113
|
+
aria-modal="true"
|
|
114
|
+
bind:this={modalElement}
|
|
115
|
+
onkeydown={handleKeydown}
|
|
116
|
+
>
|
|
117
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
118
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
119
|
+
<div class="modal-backdrop" onclick={handleBackdropClick}></div>
|
|
120
|
+
<div class="modal-content">
|
|
121
|
+
<div class="modal-header">
|
|
122
|
+
<h3 id="dispatch-modal-title" class="modal-title">
|
|
123
|
+
Dispatch Task to <span>{agent?.name || 'Agent'}</span>
|
|
124
|
+
</h3>
|
|
125
|
+
<button class="modal-close" onclick={closeDispatchModal} aria-label="Close modal">×</button>
|
|
126
|
+
</div>
|
|
127
|
+
<div class="modal-body">
|
|
128
|
+
<div class="dispatch-info">
|
|
129
|
+
<div class="info-row">
|
|
130
|
+
<span class="info-label">Issue:</span>
|
|
131
|
+
<span class="info-value">{issue.title}</span>
|
|
132
|
+
</div>
|
|
133
|
+
<div class="info-row">
|
|
134
|
+
<span class="info-label">Target:</span>
|
|
135
|
+
<span class="info-value">{issue.target}</span>
|
|
136
|
+
</div>
|
|
137
|
+
<div class="info-row">
|
|
138
|
+
<span class="info-label">Reason:</span>
|
|
139
|
+
<span class="info-value">{issue.details}</span>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
<div class="dispatch-preview">
|
|
143
|
+
<p class="preview-label">This will create a new task:</p>
|
|
144
|
+
<div class="task-preview">
|
|
145
|
+
<div class="preview-id">{taskId}</div>
|
|
146
|
+
<div class="preview-title">{taskTitle}</div>
|
|
147
|
+
<div class="preview-meta">
|
|
148
|
+
<span>Assigned to: <strong>{agent?.name || 'Agent'}</strong></span>
|
|
149
|
+
<span>Priority: <strong>{capitalize(category?.priority || 'medium')}</strong></span>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
<div class="modal-footer">
|
|
155
|
+
<button class="btn btn-secondary" onclick={closeDispatchModal}>Cancel</button>
|
|
156
|
+
<button
|
|
157
|
+
class="btn btn-primary"
|
|
158
|
+
class:loading={$dispatchLoading}
|
|
159
|
+
disabled={$dispatchLoading}
|
|
160
|
+
onclick={handleConfirm}
|
|
161
|
+
>
|
|
162
|
+
<span class="btn-text" class:hidden={$dispatchLoading}>Confirm Dispatch</span>
|
|
163
|
+
<span class="btn-spinner" class:hidden={!$dispatchLoading}></span>
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
{#if $dispatchError}
|
|
167
|
+
<div class="modal-error">
|
|
168
|
+
<span class="error-icon">!</span>
|
|
169
|
+
<span class="error-text">{$dispatchError}</span>
|
|
170
|
+
</div>
|
|
171
|
+
{/if}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
{/if}
|
|
175
|
+
|
|
176
|
+
<style>
|
|
177
|
+
.hidden {
|
|
178
|
+
visibility: hidden;
|
|
179
|
+
}
|
|
180
|
+
</style>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { wsConnected, lastUpdated, showConnectionBanner, reconnect } from '../stores/websocket.js';
|
|
3
|
+
import { openKeyboardHelp } from '../stores/ui.js';
|
|
4
|
+
import { formatRelativeTime } from '../utils/formatters.js';
|
|
5
|
+
|
|
6
|
+
$: connectionText = $wsConnected ? 'Connected' : 'Disconnected';
|
|
7
|
+
$: lastUpdatedText = $lastUpdated ? formatRelativeTime($lastUpdated) : '--';
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<!-- Connection Lost Banner -->
|
|
11
|
+
{#if $showConnectionBanner}
|
|
12
|
+
<div class="connection-banner" role="alert" aria-live="assertive">
|
|
13
|
+
<span class="banner-icon">!</span>
|
|
14
|
+
<span class="banner-text">Connection Lost - Dashboard cannot reach the forge server. Check if the daemon is running.</span>
|
|
15
|
+
<button class="banner-retry" onclick={reconnect}>Retry</button>
|
|
16
|
+
</div>
|
|
17
|
+
{/if}
|
|
18
|
+
|
|
19
|
+
<!-- svelte-ignore a11y_no_redundant_roles -->
|
|
20
|
+
<footer class="footer" role="contentinfo">
|
|
21
|
+
<div class="footer-left">
|
|
22
|
+
<span class="connection-status">
|
|
23
|
+
<span class="status-dot" class:connected={$wsConnected} class:disconnected={!$wsConnected} aria-hidden="true"></span>
|
|
24
|
+
<span>{connectionText}</span>
|
|
25
|
+
</span>
|
|
26
|
+
<span class="separator">|</span>
|
|
27
|
+
<span>Last updated: {lastUpdatedText}</span>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="footer-right">
|
|
30
|
+
<span>Vibe Forge v0.1.0</span>
|
|
31
|
+
<button class="keyboard-help-btn" onclick={openKeyboardHelp} aria-label="Keyboard shortcuts (?)">?</button>
|
|
32
|
+
</div>
|
|
33
|
+
</footer>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import { get } from 'svelte/store';
|
|
4
|
+
import { toggleTheme } from '../stores/theme.js';
|
|
5
|
+
import { refreshAll } from '../stores/websocket.js';
|
|
6
|
+
import { voiceEnabled, voiceSpeaking, toggleVoice, speak } from '../stores/voice.js';
|
|
7
|
+
|
|
8
|
+
let _greetingPlayed = false;
|
|
9
|
+
|
|
10
|
+
function playGreetingIfReady() {
|
|
11
|
+
if (get(voiceEnabled) && !_greetingPlayed) {
|
|
12
|
+
_greetingPlayed = true;
|
|
13
|
+
speak('Forge team online. Ready.', 'forge-master');
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// If voice was pre-enabled from config, play greeting on first user gesture
|
|
18
|
+
onMount(() => {
|
|
19
|
+
const handler = () => {
|
|
20
|
+
playGreetingIfReady();
|
|
21
|
+
document.removeEventListener('click', handler, { capture: true });
|
|
22
|
+
};
|
|
23
|
+
document.addEventListener('click', handler, { capture: true });
|
|
24
|
+
return () => document.removeEventListener('click', handler, { capture: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function handleVoiceToggle() {
|
|
28
|
+
toggleVoice();
|
|
29
|
+
// Play greeting when voice is turned ON (covers the manual-enable case)
|
|
30
|
+
if ($voiceEnabled && !_greetingPlayed) {
|
|
31
|
+
_greetingPlayed = true;
|
|
32
|
+
setTimeout(() => speak('Forge team online. Ready.', 'forge-master'), 50);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
</script>
|
|
36
|
+
|
|
37
|
+
<!-- svelte-ignore a11y_no_redundant_roles -->
|
|
38
|
+
<header class="header" role="banner">
|
|
39
|
+
<div class="header-left">
|
|
40
|
+
<button class="logo-btn" onclick={refreshAll} aria-label="Refresh dashboard">
|
|
41
|
+
<span class="logo-icon" aria-hidden="true">⚒</span>
|
|
42
|
+
<span class="logo-text">VIBE FORGE DASHBOARD</span>
|
|
43
|
+
</button>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="header-right">
|
|
46
|
+
<button
|
|
47
|
+
id="voice-toggle"
|
|
48
|
+
class="header-btn"
|
|
49
|
+
class:active={$voiceEnabled}
|
|
50
|
+
class:speaking={$voiceSpeaking}
|
|
51
|
+
onclick={handleVoiceToggle}
|
|
52
|
+
aria-label={$voiceEnabled ? 'Disable agent voices' : 'Enable agent voices'}
|
|
53
|
+
title={$voiceEnabled ? 'Agent voices on (click to mute)' : 'Enable agent voices'}
|
|
54
|
+
>
|
|
55
|
+
{#if $voiceEnabled}
|
|
56
|
+
<!-- Speaker with waves (voice on) -->
|
|
57
|
+
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
58
|
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
|
59
|
+
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
|
60
|
+
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
|
61
|
+
</svg>
|
|
62
|
+
{:else}
|
|
63
|
+
<!-- Speaker muted -->
|
|
64
|
+
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
65
|
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
|
66
|
+
<line x1="23" y1="9" x2="17" y2="15"/>
|
|
67
|
+
<line x1="17" y1="9" x2="23" y2="15"/>
|
|
68
|
+
</svg>
|
|
69
|
+
{/if}
|
|
70
|
+
</button>
|
|
71
|
+
<button id="theme-toggle" class="header-btn" onclick={toggleTheme} aria-label="Toggle dark/light mode" title="Toggle theme (t)">
|
|
72
|
+
<svg class="icon sun-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
73
|
+
<circle cx="12" cy="12" r="5"/>
|
|
74
|
+
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
|
|
75
|
+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
|
76
|
+
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
|
|
77
|
+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
|
78
|
+
</svg>
|
|
79
|
+
<svg class="icon moon-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
80
|
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
|
81
|
+
</svg>
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
</header>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { getCategoryConfig, openDispatchModal } from '../stores/issues.js';
|
|
3
|
+
import { getAgentConfig } from '../stores/agents.js';
|
|
4
|
+
|
|
5
|
+
export let issue;
|
|
6
|
+
|
|
7
|
+
$: category = getCategoryConfig(issue.category);
|
|
8
|
+
$: agent = getAgentConfig(issue.agent);
|
|
9
|
+
|
|
10
|
+
function handleDispatch(event) {
|
|
11
|
+
event.stopPropagation();
|
|
12
|
+
openDispatchModal(issue);
|
|
13
|
+
}
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
17
|
+
<div class="card issue-card" data-issue-id={issue.id} tabindex="0">
|
|
18
|
+
<div class="issue-category {issue.category}">{category.icon}</div>
|
|
19
|
+
<div class="issue-content">
|
|
20
|
+
<div class="issue-header">
|
|
21
|
+
<span class="issue-badge {issue.category}">{category.label}</span>
|
|
22
|
+
<span class="issue-title">{issue.title}</span>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="issue-details">{issue.details}</div>
|
|
25
|
+
<div class="issue-meta">{issue.meta}</div>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="issue-dispatch">
|
|
28
|
+
<button class="btn btn-dispatch" onclick={handleDispatch}>
|
|
29
|
+
<span class="btn-text">Dispatch {agent ? agent.short : 'Agent'}</span>
|
|
30
|
+
<span class="btn-arrow">»</span>
|
|
31
|
+
</button>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { issues, issuesLoading, issuesError, issuesCollapsed, toggleIssuesCollapse } from '../stores/issues.js';
|
|
3
|
+
import { refreshIssues } from '../stores/websocket.js';
|
|
4
|
+
import IssueCard from './IssueCard.svelte';
|
|
5
|
+
|
|
6
|
+
let refreshing = false;
|
|
7
|
+
|
|
8
|
+
async function handleRefresh() {
|
|
9
|
+
refreshing = true;
|
|
10
|
+
await refreshIssues();
|
|
11
|
+
refreshing = false;
|
|
12
|
+
}
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<section
|
|
16
|
+
id="issues-panel"
|
|
17
|
+
class="panel issues-panel"
|
|
18
|
+
class:collapsed={$issuesCollapsed}
|
|
19
|
+
aria-labelledby="issues-heading"
|
|
20
|
+
tabindex="-1"
|
|
21
|
+
>
|
|
22
|
+
<div class="panel-header">
|
|
23
|
+
<h2 id="issues-heading" class="panel-title">ISSUES <span class="panel-subtitle">(Actionable)</span></h2>
|
|
24
|
+
<div class="panel-actions">
|
|
25
|
+
<button
|
|
26
|
+
class="panel-refresh"
|
|
27
|
+
class:loading={refreshing}
|
|
28
|
+
onclick={handleRefresh}
|
|
29
|
+
aria-label="Refresh issues"
|
|
30
|
+
>
|
|
31
|
+
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
32
|
+
<path d="M23 4v6h-6M1 20v-6h6"/>
|
|
33
|
+
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>
|
|
34
|
+
</svg>
|
|
35
|
+
</button>
|
|
36
|
+
<button
|
|
37
|
+
class="panel-collapse"
|
|
38
|
+
onclick={toggleIssuesCollapse}
|
|
39
|
+
aria-label="Collapse issues panel"
|
|
40
|
+
>
|
|
41
|
+
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
42
|
+
<polyline points="18 15 12 9 6 15"/>
|
|
43
|
+
</svg>
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
{#if $issuesLoading}
|
|
49
|
+
<div class="loading-state">
|
|
50
|
+
<div class="skeleton-issue"></div>
|
|
51
|
+
<div class="skeleton-issue"></div>
|
|
52
|
+
</div>
|
|
53
|
+
{:else if $issuesError}
|
|
54
|
+
<div class="error-state">
|
|
55
|
+
<div class="error-icon" aria-hidden="true">!</div>
|
|
56
|
+
<p class="error-title">Failed to load issues</p>
|
|
57
|
+
<p class="error-message">{$issuesError}</p>
|
|
58
|
+
<button class="btn btn-secondary" onclick={handleRefresh}>Retry</button>
|
|
59
|
+
</div>
|
|
60
|
+
{:else if $issues.length === 0}
|
|
61
|
+
<div class="empty-state celebration">
|
|
62
|
+
<div class="empty-icon" aria-hidden="true">🎉</div>
|
|
63
|
+
<p class="empty-title">Everything looks great!</p>
|
|
64
|
+
<p class="empty-subtitle">No stale docs, failing tests, or security issues</p>
|
|
65
|
+
</div>
|
|
66
|
+
{:else}
|
|
67
|
+
<div id="issues-list" class="panel-content issues-list" aria-busy="false">
|
|
68
|
+
{#each $issues as issue (issue.id)}
|
|
69
|
+
<IssueCard {issue} />
|
|
70
|
+
{/each}
|
|
71
|
+
</div>
|
|
72
|
+
{/if}
|
|
73
|
+
</section>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<script>
|
|
2
|
+
import { showKeyboardHelp, closeKeyboardHelp } from '../stores/ui.js';
|
|
3
|
+
import { onDestroy } from 'svelte';
|
|
4
|
+
|
|
5
|
+
let modalElement;
|
|
6
|
+
let previousFocus = null;
|
|
7
|
+
|
|
8
|
+
function handleBackdropClick() {
|
|
9
|
+
closeKeyboardHelp();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function handleKeydown(event) {
|
|
13
|
+
if (event.key === 'Escape') {
|
|
14
|
+
closeKeyboardHelp();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Focus trap
|
|
18
|
+
if (event.key === 'Tab' && modalElement) {
|
|
19
|
+
const focusableElements = modalElement.querySelectorAll(
|
|
20
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
if (focusableElements.length === 0) {
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const firstFocusable = focusableElements[0];
|
|
29
|
+
const lastFocusable = focusableElements[focusableElements.length - 1];
|
|
30
|
+
|
|
31
|
+
if (event.shiftKey) {
|
|
32
|
+
if (document.activeElement === firstFocusable) {
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
lastFocusable.focus();
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
if (document.activeElement === lastFocusable) {
|
|
38
|
+
event.preventDefault();
|
|
39
|
+
firstFocusable.focus();
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Focus management
|
|
46
|
+
$: if ($showKeyboardHelp && modalElement) {
|
|
47
|
+
previousFocus = document.activeElement;
|
|
48
|
+
setTimeout(() => {
|
|
49
|
+
const closeBtn = modalElement?.querySelector('.modal-close');
|
|
50
|
+
closeBtn?.focus();
|
|
51
|
+
}, 100);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
onDestroy(() => {
|
|
55
|
+
if (previousFocus) {
|
|
56
|
+
previousFocus.focus();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
{#if $showKeyboardHelp}
|
|
62
|
+
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
63
|
+
<div
|
|
64
|
+
class="modal"
|
|
65
|
+
role="dialog"
|
|
66
|
+
aria-labelledby="keyboard-modal-title"
|
|
67
|
+
aria-modal="true"
|
|
68
|
+
bind:this={modalElement}
|
|
69
|
+
onkeydown={handleKeydown}
|
|
70
|
+
>
|
|
71
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
72
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
73
|
+
<div class="modal-backdrop" onclick={handleBackdropClick}></div>
|
|
74
|
+
<div class="modal-content keyboard-help">
|
|
75
|
+
<div class="modal-header">
|
|
76
|
+
<h3 id="keyboard-modal-title" class="modal-title">Keyboard Shortcuts</h3>
|
|
77
|
+
<button class="modal-close" onclick={closeKeyboardHelp} aria-label="Close modal">×</button>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="modal-body">
|
|
80
|
+
<div class="shortcut-section">
|
|
81
|
+
<h4>Navigation</h4>
|
|
82
|
+
<div class="shortcut-list">
|
|
83
|
+
<div class="shortcut"><kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd> <kbd>4</kbd> <span>Focus panels (Tasks, Agents, Notifications, Issues)</span></div>
|
|
84
|
+
<div class="shortcut"><kbd>j</kbd> / <kbd>k</kbd> <span>Move down / up in list</span></div>
|
|
85
|
+
<div class="shortcut"><kbd>Enter</kbd> <span>Expand / select item</span></div>
|
|
86
|
+
<div class="shortcut"><kbd>Escape</kbd> <span>Close / deselect</span></div>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
<div class="shortcut-section">
|
|
90
|
+
<h4>Actions</h4>
|
|
91
|
+
<div class="shortcut-list">
|
|
92
|
+
<div class="shortcut"><kbd>d</kbd> <span>Dispatch (on issue)</span></div>
|
|
93
|
+
<div class="shortcut"><kbd>r</kbd> <span>Refresh current panel</span></div>
|
|
94
|
+
<div class="shortcut"><kbd>t</kbd> <span>Toggle theme</span></div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
<div class="shortcut-section">
|
|
98
|
+
<h4>General</h4>
|
|
99
|
+
<div class="shortcut-list">
|
|
100
|
+
<div class="shortcut"><kbd>?</kbd> <span>Show this help</span></div>
|
|
101
|
+
<div class="shortcut"><kbd>Tab</kbd> <span>Next element</span></div>
|
|
102
|
+
<div class="shortcut"><kbd>Shift</kbd>+<kbd>Tab</kbd> <span>Previous element</span></div>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
{/if}
|