diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results
index 1329119..719ba7f 100644
--- a/.phpunit.cache/test-results
+++ b/.phpunit.cache/test-results
@@ -1 +1 @@
-{"version":2,"defects":{"Tests\\Support\\FrontMatterTest::testDumpAndParseRoundTrip":7,"Tests\\Api\\BoardApiTest::testBoardStateEndpointReturnsProjectState":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointRejectsMissingCsrfToken":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointCreatesTaskWithValidCsrfToken":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointReturnsConflictForStaleRevision":7,"Tests\\Api\\BoardApiTest::testSaveTaskEndpointUpdatesTaskFields":7},"times":{"Tests\\Domain\\BoardTest::testToArrayIncludesSerializedColumnsAndMeta":0,"Tests\\Support\\FrontMatterTest::testDumpAndParseRoundTrip":0,"Tests\\Support\\FrontMatterTest::testParseWithoutFrontMatterReturnsBodyOnly":0,"Tests\\Service\\BoardServiceTest::testGetBoardStateBuildsDefaultColumnsForNewProject":0.015,"Tests\\Service\\BoardServiceTest::testCreateTaskPersistsTaskInRequestedColumn":0.075,"Tests\\Service\\BoardServiceTest::testDeleteTaskMovesTaskToTrashByDefault":0.132,"Tests\\Service\\BoardServiceTest::testUpdateBoardMovesDeletedColumnTasksIntoTrash":0.153,"Tests\\Api\\BoardApiTest::testBoardStateEndpointReturnsProjectState":0.036,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointRejectsMissingCsrfToken":0.004,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointCreatesTaskWithValidCsrfToken":0.07,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointReturnsConflictForStaleRevision":0.073,"Tests\\Api\\BoardApiTest::testMoveTaskEndpointMovesTaskToRequestedColumn":0.121,"Tests\\Api\\BoardApiTest::testDeleteTaskEndpointMovesTaskToTrash":0.107,"Tests\\Api\\BoardApiTest::testSaveNoteEndpointCreatesNote":0.061,"Tests\\Api\\BoardApiTest::testCreateColumnEndpointCreatesNewColumn":0.072,"Tests\\Api\\BoardApiTest::testSaveTaskEndpointUpdatesTaskFields":0.139,"Tests\\Api\\BoardApiTest::testUpdateBoardEndpointReplacesColumnDefinitions":0.074}}
\ No newline at end of file
+{"version":2,"defects":{"Tests\\Support\\FrontMatterTest::testDumpAndParseRoundTrip":7,"Tests\\Api\\BoardApiTest::testBoardStateEndpointReturnsProjectState":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointRejectsMissingCsrfToken":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointCreatesTaskWithValidCsrfToken":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointReturnsConflictForStaleRevision":7,"Tests\\Api\\BoardApiTest::testSaveTaskEndpointUpdatesTaskFields":7},"times":{"Tests\\Domain\\BoardTest::testToArrayIncludesSerializedColumnsAndMeta":0,"Tests\\Support\\FrontMatterTest::testDumpAndParseRoundTrip":0,"Tests\\Support\\FrontMatterTest::testParseWithoutFrontMatterReturnsBodyOnly":0,"Tests\\Service\\BoardServiceTest::testGetBoardStateBuildsDefaultColumnsForNewProject":0.009,"Tests\\Service\\BoardServiceTest::testCreateTaskPersistsTaskInRequestedColumn":0.076,"Tests\\Service\\BoardServiceTest::testDeleteTaskMovesTaskToTrashByDefault":0.103,"Tests\\Service\\BoardServiceTest::testUpdateBoardMovesDeletedColumnTasksIntoTrash":0.107,"Tests\\Api\\BoardApiTest::testBoardStateEndpointReturnsProjectState":0.03,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointRejectsMissingCsrfToken":0.004,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointCreatesTaskWithValidCsrfToken":0.068,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointReturnsConflictForStaleRevision":0.059,"Tests\\Api\\BoardApiTest::testMoveTaskEndpointMovesTaskToRequestedColumn":0.106,"Tests\\Api\\BoardApiTest::testDeleteTaskEndpointMovesTaskToTrash":0.106,"Tests\\Api\\BoardApiTest::testSaveNoteEndpointCreatesNote":0.06,"Tests\\Api\\BoardApiTest::testCreateColumnEndpointCreatesNewColumn":0.059,"Tests\\Api\\BoardApiTest::testSaveTaskEndpointUpdatesTaskFields":0.109,"Tests\\Api\\BoardApiTest::testUpdateBoardEndpointReplacesColumnDefinitions":0.073,"Tests\\Api\\BoardApiTest::testCreateProjectEndpointCreatesProject":0.066,"Tests\\Service\\BoardServiceTest::testCreateProjectBuildsInitialBoardState":0.065}}
\ No newline at end of file
diff --git a/public/api/create-project.php b/public/api/create-project.php
new file mode 100644
index 0000000..042fcb0
--- /dev/null
+++ b/public/api/create-project.php
@@ -0,0 +1,34 @@
+ true,
+ 'state' => $boardService->createProject(
+ trim((string) ($input['title'] ?? 'Untitled Project')),
+ trim((string) ($input['body'] ?? '')),
+ isset($input['slug']) ? trim((string) $input['slug']) : null
+ ),
+ ]
+ );
+} catch (HttpHalt $halt) {
+ return;
+} catch (Throwable $exception) {
+ Http::error($exception->getMessage(), 500);
+}
diff --git a/public/assets/app.css b/public/assets/app.css
index e475aa0..c8dc4a1 100644
--- a/public/assets/app.css
+++ b/public/assets/app.css
@@ -34,6 +34,9 @@ select {
font: inherit;
}
+a {
+ color: inherit;
+}
code {
font-family: "Cascadia Code", Consolas, monospace;
}
@@ -59,12 +62,17 @@ code {
min-width: 0;
}
+.sidebar-actions {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
.workspace-stack {
display: grid;
gap: 20px;
min-width: 0;
}
-
.eyebrow {
margin: 0 0 8px;
color: var(--accent);
@@ -214,10 +222,13 @@ p {
}
.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
padding: 11px 18px;
border-radius: 999px;
+ text-decoration: none;
}
-
.button.primary {
border-color: transparent;
background: linear-gradient(135deg, var(--accent) 0%, #70e0c6 100%);
@@ -235,6 +246,135 @@ p {
padding-bottom: 8px;
}
+.dashboard-intro,
+.project-create-panel,
+.projects-panel {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--panel);
+ box-shadow: var(--shadow);
+}
+
+.dashboard-intro,
+.project-create-panel,
+.projects-panel {
+ padding: 22px;
+}
+
+.dashboard-intro {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 24px;
+ margin-bottom: 20px;
+}
+
+.dashboard-intro-meta {
+ display: grid;
+ gap: 8px;
+ color: var(--muted);
+ text-align: right;
+}
+
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
+ gap: 20px;
+ align-items: start;
+}
+
+.project-create-form {
+ display: grid;
+ gap: 16px;
+}
+
+.project-create-form label {
+ display: grid;
+ gap: 8px;
+}
+
+.project-create-form input,
+.project-create-form textarea {
+ width: 100%;
+ padding: 12px 14px;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: rgba(255, 255, 255, 0.03);
+ color: var(--text);
+}
+
+.project-create-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.form-hint {
+ margin-bottom: 0;
+ color: var(--muted);
+ font-size: 0.92rem;
+}
+
+.project-card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 16px;
+}
+
+.project-card {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ min-height: 220px;
+ padding: 18px;
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ border-radius: 20px;
+ background:
+ linear-gradient(180deg, rgba(27, 39, 54, 0.94), rgba(14, 21, 31, 0.96)),
+ radial-gradient(circle at top right, rgba(125, 211, 167, 0.14), transparent 38%);
+ box-shadow: var(--shadow);
+ text-decoration: none;
+ transition:
+ transform 160ms ease,
+ border-color 160ms ease,
+ background 160ms ease;
+}
+
+.project-card:hover {
+ transform: translateY(-2px);
+ border-color: rgba(125, 211, 167, 0.38);
+ background:
+ linear-gradient(180deg, rgba(31, 45, 62, 0.98), rgba(16, 23, 35, 0.98)),
+ radial-gradient(circle at top right, rgba(125, 211, 167, 0.18), transparent 40%);
+}
+
+.project-card-body {
+ flex: 1;
+ color: var(--muted);
+}
+
+.project-card-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: auto;
+}
+
+.project-card-slug {
+ color: var(--muted);
+ font-size: 0.9rem;
+}
+
+.project-card-cta {
+ color: var(--accent);
+ font-weight: 700;
+}
+
+.error-state {
+ margin-bottom: 20px;
+}
.trash-columns-wrap {
margin-top: 18px;
}
@@ -501,6 +641,19 @@ p {
.notes-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
+
+ .dashboard-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .dashboard-intro {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .dashboard-intro-meta {
+ text-align: left;
+ }
}
@media (max-width: 720px) {
@@ -529,6 +682,12 @@ p {
width: 100%;
}
+ .sidebar-actions,
+ .project-create-actions {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
.header-actions .button {
flex: 1;
}
@@ -537,10 +696,14 @@ p {
flex: 1;
}
- .trash-panel .column {
+ .sidebar-actions .button,
+ .project-create-actions .button {
width: 100%;
}
+ .trash-panel .column {
+ width: 100%;
+ }
.column {
width: min(320px, calc(100vw - 52px));
}
diff --git a/public/assets/app.js b/public/assets/app.js
index 0c0e9e9..613067b 100644
--- a/public/assets/app.js
+++ b/public/assets/app.js
@@ -1,9 +1,67 @@
const initialStateNode = document.getElementById('initial-state');
const appRoot = document.getElementById('app');
+const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
+const projectCreateForm = document.getElementById('project-create-form');
+
+const fetchJson = async (url, options = {}) => {
+ const response = await fetch(url, {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': csrfToken,
+ ...(options.headers || {}),
+ },
+ ...options,
+ });
+
+ const payload = await response.json();
+
+ if (!response.ok || payload.success === false) {
+ throw new Error(payload.error || 'Request failed');
+ }
+
+ return payload;
+};
+
+const handleError = (error) => {
+ window.alert(error.message || 'Something went wrong.');
+};
+
+if (projectCreateForm) {
+ projectCreateForm.addEventListener('submit', async (event) => {
+ event.preventDefault();
+
+ const formData = new FormData(projectCreateForm);
+ const submitButton = projectCreateForm.querySelector('button[type="submit"]');
+
+ if (submitButton) {
+ submitButton.disabled = true;
+ }
+
+ try {
+ const payload = await fetchJson('/api/create-project.php', {
+ method: 'POST',
+ body: JSON.stringify({
+ title: formData.get('title'),
+ slug: formData.get('slug'),
+ body: formData.get('body'),
+ }),
+ });
+
+ window.location.assign(
+ payload.state.projectUrl || `/?project=${encodeURIComponent(payload.state.project.id)}`
+ );
+ } catch (error) {
+ handleError(error);
+
+ if (submitButton) {
+ submitButton.disabled = false;
+ }
+ }
+ });
+}
if (initialStateNode && appRoot && initialStateNode.textContent !== 'null') {
const state = JSON.parse(initialStateNode.textContent);
- const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const boardColumnsEl = document.getElementById('board-columns');
const trashColumnsEl = document.getElementById('trash-columns');
const trashDropzoneEl = document.getElementById('trash-dropzone');
@@ -39,25 +97,6 @@ if (initialStateNode && appRoot && initialStateNode.textContent !== 'null') {
const markdownPreview = (value = '') => escapeHtml(value).replaceAll('\n', '
');
- const fetchJson = async (url, options = {}) => {
- const response = await fetch(url, {
- headers: {
- 'Content-Type': 'application/json',
- 'X-CSRF-Token': csrfToken,
- ...(options.headers || {}),
- },
- ...options,
- });
-
- const payload = await response.json();
-
- if (!response.ok || payload.success === false) {
- throw new Error(payload.error || 'Request failed');
- }
-
- return payload;
- };
-
const updateState = (nextState) => {
state.project = nextState.project;
state.board = nextState.board;
@@ -394,10 +433,6 @@ if (initialStateNode && appRoot && initialStateNode.textContent !== 'null') {
noteDialog.showModal();
};
- const handleError = (error) => {
- window.alert(error.message || 'Something went wrong.');
- };
-
const reloadBoard = async () => {
const response = await fetch(`/api/board-state.php?project=${encodeURIComponent(projectId)}`);
updateState(await response.json());
diff --git a/public/index.php b/public/index.php
index 9a20857..447a45b 100644
--- a/public/index.php
+++ b/public/index.php
@@ -15,15 +15,19 @@ use IronKanban\Service\BoardService;
$projectRepository = new ProjectRepository();
$projects = $projectRepository->getAll();
$requestedProject = isset($_GET['project']) ? trim((string) $_GET['project']) : '';
-$activeProjectId = $requestedProject !== '' ? $requestedProject : ($projects[0]->id ?? '');
+$activeProjectId = $requestedProject;
$boardService = new BoardService();
$initialState = null;
+$loadError = null;
+$showDashboard = $requestedProject === '';
if ($activeProjectId !== '') {
try {
$initialState = $boardService->getBoardState($activeProjectId);
+ $showDashboard = false;
} catch (Throwable $exception) {
- $initialState = null;
+ $showDashboard = true;
+ $loadError = 'The requested project could not be loaded. Pick another project or create a new one.';
}
}
?>
@@ -44,6 +48,16 @@ if ($activeProjectId !== '') {
IronKanban
+
+
+
+
Storage
@@ -70,11 +85,83 @@ if ($activeProjectId !== '') {
-
-
-
- No project selected
- Create or copy a project folder into using the markdown layout from the notes.
+
+
+
+
+
Project Dashboard
+
Choose a project or start a fresh one
+
Each project stays as markdown on disk, with its own notes, tasks, board definition, and revision tracking.
+
+
+ project
+ Stored in
+
+
+
+
+
+ Project unavailable
+
+
+
+
+
+
+
+
+
+
Projects
+
+
+
+
+
No projects yet
+
Create your first project to generate a board with default kanban columns.
+
+
+
+
+ body) ?? '');
+ $preview = $preview !== '' ? $preview : 'No project description yet.';
+ $preview = strlen($preview) > 180 ? substr($preview, 0, 177) . '...' : $preview;
+ ?>
+
+ Project
+ title); ?>
+
+
+
+
+
+
+
diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php
index 4e96634..8487120 100644
--- a/src/Repository/ProjectRepository.php
+++ b/src/Repository/ProjectRepository.php
@@ -10,6 +10,7 @@ declare(strict_types=1);
namespace IronKanban\Repository;
use IronKanban\Domain\Project;
+use IronKanban\Support\AtomicWriter;
use IronKanban\Support\FrontMatter;
use IronKanban\Support\Path;
@@ -18,6 +19,43 @@ use IronKanban\Support\Path;
*/
final class ProjectRepository
{
+ /**
+ * Create a new project in markdown storage.
+ *
+ * @param string $title Project title.
+ * @param string $body Project description body.
+ * @param string|null $slug Optional requested project slug.
+ *
+ * @return Project
+ */
+ public function create(string $title, string $body = '', ?string $slug = null): Project
+ {
+ ensure_directory(project_root());
+
+ $normalizedTitle = trim($title) !== '' ? trim($title) : 'Untitled Project';
+ $baseSlug = slugify($slug !== null && trim($slug) !== '' ? $slug : $normalizedTitle);
+ $projectId = $this->_nextAvailableSlug($baseSlug);
+ $projectPath = Path::project($projectId);
+
+ ensure_directory($projectPath . DIRECTORY_SEPARATOR . 'notes');
+ ensure_directory($projectPath . DIRECTORY_SEPARATOR . 'tasks');
+
+ AtomicWriter::write(
+ $projectPath . DIRECTORY_SEPARATOR . 'index.md',
+ FrontMatter::dump(
+ [
+ 'type' => 'project',
+ 'id' => $projectId,
+ 'title' => $normalizedTitle,
+ 'created' => now_iso8601(),
+ ],
+ trim($body)
+ )
+ );
+
+ return $this->get($projectId);
+ }
+
/**
* Load all available projects.
*
@@ -72,4 +110,24 @@ final class ProjectRepository
$document->meta
);
}
+
+ /**
+ * Find the next available project slug.
+ *
+ * @param string $baseSlug Base slug candidate.
+ *
+ * @return string
+ */
+ private function _nextAvailableSlug(string $baseSlug): string
+ {
+ $slug = $baseSlug;
+ $suffix = 2;
+
+ while (is_dir(project_root() . DIRECTORY_SEPARATOR . $slug)) {
+ $slug = $baseSlug . '-' . $suffix;
+ $suffix++;
+ }
+
+ return $slug;
+ }
}
diff --git a/src/Service/BoardService.php b/src/Service/BoardService.php
index f2d3ae2..d6cc71c 100644
--- a/src/Service/BoardService.php
+++ b/src/Service/BoardService.php
@@ -70,6 +70,24 @@ final class BoardService
];
}
+ /**
+ * Create a new project and return its initial board state.
+ *
+ * @param string $title Project title.
+ * @param string $body Project description body.
+ * @param string|null $slug Optional requested slug.
+ *
+ * @return array
+ */
+ public function createProject(string $title, string $body = '', ?string $slug = null): array
+ {
+ $project = $this->projectRepository->create($title, $body, $slug);
+ $state = $this->touchProject($project->id, sprintf('feat(project): create %s', $project->id));
+ $state['projectUrl'] = '/?project=' . rawurlencode($project->id);
+
+ return $state;
+ }
+
/**
* Move a task to a new column and index.
*
diff --git a/tests/Api/BoardApiTest.php b/tests/Api/BoardApiTest.php
index 2506102..a9a4010 100644
--- a/tests/Api/BoardApiTest.php
+++ b/tests/Api/BoardApiTest.php
@@ -25,7 +25,6 @@ final class BoardApiTest extends IntegrationTestCase
public function testBoardStateEndpointReturnsProjectState(): void
{
$projectId = $this->createProject();
-
$response = $this->callApi(
'public/api/board-state.php',
'GET',
@@ -37,6 +36,36 @@ final class BoardApiTest extends IntegrationTestCase
self::assertContains('trash', array_column($response['payload']['columns'], 'id'));
}
+ /**
+ * Ensure create-project creates markdown storage and returns the new project state.
+ *
+ * @return void
+ */
+ public function testCreateProjectEndpointCreatesProject(): void
+ {
+ $token = csrf_token();
+
+ $response = $this->callApi(
+ 'public/api/create-project.php',
+ 'POST',
+ [],
+ [
+ 'title' => 'Release Planning',
+ 'slug' => 'release-planning',
+ 'body' => 'Track launch work.',
+ '_token' => $token,
+ ],
+ ['HTTP_X_CSRF_TOKEN' => $token]
+ );
+
+ self::assertSame(200, $response['status']);
+ self::assertTrue($response['payload']['success']);
+ self::assertSame('release-planning', $response['payload']['state']['project']['id']);
+ self::assertSame('/?project=release-planning', $response['payload']['state']['projectUrl']);
+ self::assertContains('trash', array_column($response['payload']['state']['columns'], 'id'));
+ self::assertFileExists($this->projectRoot . DIRECTORY_SEPARATOR . 'release-planning' . DIRECTORY_SEPARATOR . 'index.md');
+ }
+
/**
* Ensure state-changing endpoints reject missing CSRF tokens.
*
diff --git a/tests/Service/BoardServiceTest.php b/tests/Service/BoardServiceTest.php
index ce5327d..acf564d 100644
--- a/tests/Service/BoardServiceTest.php
+++ b/tests/Service/BoardServiceTest.php
@@ -26,7 +26,6 @@ final class BoardServiceTest extends IntegrationTestCase
{
$projectId = $this->createProject();
$service = new BoardService();
-
$state = $service->getBoardState($projectId);
self::assertSame(
@@ -38,6 +37,27 @@ final class BoardServiceTest extends IntegrationTestCase
self::assertSame('Demo Project', $state['project']['title']);
}
+ /**
+ * Ensure creating a project initializes markdown storage and default board state.
+ *
+ * @return void
+ */
+ public function testCreateProjectBuildsInitialBoardState(): void
+ {
+ $service = new BoardService();
+
+ $state = $service->createProject('Release Planning', 'Track launch work.', 'release-planning');
+
+ self::assertSame('release-planning', $state['project']['id']);
+ self::assertSame('Release Planning', $state['project']['title']);
+ self::assertSame('Track launch work.', trim((string) $state['project']['body']));
+ self::assertSame(
+ ['backlog', 'ready', 'in-progress', 'review', 'done', 'trash'],
+ array_column($state['columns'], 'id')
+ );
+ self::assertFileExists($this->projectRoot . DIRECTORY_SEPARATOR . 'release-planning' . DIRECTORY_SEPARATOR . 'index.md');
+ }
+
/**
* Ensure a created task is persisted and returned in board state.
*
diff --git a/tests/e2e/app.spec.js b/tests/e2e/app.spec.js
index ab5db57..d78eef9 100644
--- a/tests/e2e/app.spec.js
+++ b/tests/e2e/app.spec.js
@@ -1,6 +1,15 @@
const { test, expect } = require('@playwright/test');
test.describe('IronKanban smoke', () => {
+ test('loads the project dashboard with cards and create form', async ({ page }) => {
+ await page.goto('/');
+
+ await expect(page.getByRole('heading', { name: 'Choose a project or start a fresh one' })).toBeVisible();
+ await expect(page.locator('#project-create-form')).toBeVisible();
+ await expect(page.locator('.project-card')).toHaveCount(1);
+ await expect(page.locator('.project-card h3')).toHaveText(['Demo Project']);
+ });
+
test('loads the demo board and core UI sections', async ({ page }) => {
await page.goto('/?project=demo-project');