feat: add workspace sidebar skeleton

This commit is contained in:
Keisuke Hirata 2026-06-22 01:45:46 +09:00
parent 2c7ef24a29
commit d3b8bdfddc
No known key found for this signature in database
6 changed files with 727 additions and 66 deletions

View File

@ -0,0 +1,157 @@
<script lang="ts">
import type { ObjectiveListResponse, ObjectiveSummary } from './types';
const MAX_VISIBLE_OBJECTIVES = 6;
let loading = $state(true);
let error = $state<string | null>(null);
let objectives = $state<ObjectiveSummary[]>([]);
let invalidRecordCount = $state(0);
$effect(() => {
const controller = new AbortController();
void loadObjectives(controller.signal);
return () => controller.abort();
});
async function loadObjectives(signal: AbortSignal) {
loading = true;
error = null;
try {
const response = await fetch('/api/objectives', { signal });
if (!response.ok) {
throw new Error(`objectives request failed (${response.status})`);
}
const payload = (await response.json()) as ObjectiveListResponse;
objectives = Array.isArray(payload.items) ? payload.items.slice(0, MAX_VISIBLE_OBJECTIVES) : [];
invalidRecordCount = Array.isArray(payload.invalid_records) ? payload.invalid_records.length : 0;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
error = err instanceof Error ? err.message : 'objectives request failed';
objectives = [];
invalidRecordCount = 0;
} finally {
if (!signal.aborted) {
loading = false;
}
}
}
</script>
<section class="nav-section" aria-labelledby="objectives-heading">
<div class="section-heading-row">
<h2 id="objectives-heading">objectives</h2>
{#if !loading && !error}
<span class="section-count">{objectives.length}</span>
{/if}
</div>
{#if loading}
<p class="section-state">Loading objectives…</p>
{:else if error}
<p class="section-state error">{error}</p>
{:else if objectives.length === 0}
<p class="section-state">No objectives found.</p>
{:else}
<ul class="nav-list" aria-label="Objectives">
{#each objectives as objective (objective.id)}
<li class="nav-item">
<span class="item-title">{objective.title}</span>
<span class="item-meta">{objective.state}</span>
</li>
{/each}
</ul>
{/if}
{#if invalidRecordCount > 0}
<p class="section-note">{invalidRecordCount} invalid objective record(s) hidden.</p>
{/if}
</section>
<style>
.nav-section {
display: grid;
gap: 10px;
}
.section-heading-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h2 {
margin: 0;
color: #94a3b8;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.section-count {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 999px;
color: #cbd5e1;
font-size: 0.72rem;
line-height: 1;
padding: 4px 8px;
}
.nav-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.nav-item {
display: grid;
gap: 3px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 14px;
background: rgba(15, 23, 42, 0.64);
padding: 10px 12px;
min-width: 0;
}
.item-title,
.item-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-title {
color: #e2e8f0;
font-weight: 700;
}
.item-meta,
.section-state,
.section-note {
color: #94a3b8;
font-size: 0.78rem;
}
.section-state,
.section-note {
margin: 0;
line-height: 1.45;
}
.section-state {
border: 1px dashed rgba(148, 163, 184, 0.2);
border-radius: 14px;
padding: 10px 12px;
}
.section-state.error {
border-color: rgba(248, 113, 113, 0.36);
color: #fecaca;
}
</style>

View File

@ -0,0 +1,106 @@
<script lang="ts">
import type { WorkspaceResponse } from './types';
type Props = {
workspace: WorkspaceResponse | null;
};
let { workspace }: Props = $props();
</script>
<section class="nav-section" aria-labelledby="repositories-heading">
<div class="section-heading-row">
<h2 id="repositories-heading">repositories</h2>
<span class="section-count">1</span>
</div>
<ul class="nav-list" aria-label="Repositories">
<li class="nav-item active">
<span class="item-title">{workspace?.display_name ?? 'local workspace'}</span>
<span class="item-meta">local project records</span>
</li>
</ul>
<p class="section-note">
Repository API is not wired yet; this placeholder keeps the navigation seam
ready without adding repository authority.
</p>
</section>
<style>
.nav-section {
display: grid;
gap: 10px;
}
.section-heading-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h2 {
margin: 0;
color: #94a3b8;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.section-count {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 999px;
color: #cbd5e1;
font-size: 0.72rem;
line-height: 1;
padding: 4px 8px;
}
.nav-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.nav-item {
display: grid;
gap: 3px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 14px;
background: rgba(15, 23, 42, 0.64);
padding: 10px 12px;
min-width: 0;
}
.nav-item.active {
border-color: rgba(56, 189, 248, 0.36);
background: rgba(14, 165, 233, 0.1);
}
.item-title,
.item-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-title {
color: #e2e8f0;
font-weight: 700;
}
.item-meta,
.section-note {
color: #94a3b8;
font-size: 0.78rem;
}
.section-note {
margin: 0;
line-height: 1.45;
}
</style>

View File

@ -0,0 +1,198 @@
<script lang="ts">
import type { WorkerSummary } from './types';
const MAX_VISIBLE_WORKERS = 6;
let loading = $state(true);
let error = $state<string | null>(null);
let workers = $state<WorkerSummary[]>([]);
let placeholder = $state<string | null>(null);
$effect(() => {
const controller = new AbortController();
void loadWorkers(controller.signal);
return () => controller.abort();
});
async function loadWorkers(signal: AbortSignal) {
loading = true;
error = null;
placeholder = null;
try {
const response = await fetch('/api/workers', { signal });
if (response.status === 404) {
workers = [];
placeholder = 'Worker API is not integrated in this build yet.';
return;
}
if (!response.ok) {
throw new Error(`workers request failed (${response.status})`);
}
const payload = await response.json();
workers = normalizeWorkers(payload).slice(0, MAX_VISIBLE_WORKERS);
if (workers.length === 0) {
placeholder = 'No workers reported by the current API.';
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return;
}
error = err instanceof Error ? err.message : 'workers request failed';
workers = [];
} finally {
if (!signal.aborted) {
loading = false;
}
}
}
function normalizeWorkers(payload: unknown): WorkerSummary[] {
const items = Array.isArray(payload)
? payload
: isRecord(payload) && Array.isArray(payload.items)
? payload.items
: [];
return items.map((item, index) => normalizeWorker(item, index));
}
function normalizeWorker(item: unknown, index: number): WorkerSummary {
if (!isRecord(item)) {
return {
id: `worker-${index + 1}`,
label: `worker ${index + 1}`,
status: 'unknown'
};
}
const id = readText(item, ['id', 'worker_id', 'name']) ?? `worker-${index + 1}`;
const label = readText(item, ['display_name', 'label', 'name', 'worker_id', 'id']) ?? id;
const status = readText(item, ['status', 'state', 'lifecycle']) ?? 'unknown';
const detail = readText(item, ['role', 'profile', 'note']);
return { id, label, status, detail };
}
function readText(record: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = record[key];
if (typeof value === 'string' && value.trim().length > 0) {
return value;
}
}
return null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
</script>
<section class="nav-section" aria-labelledby="workers-heading">
<div class="section-heading-row">
<h2 id="workers-heading">workers</h2>
{#if !loading && !error && workers.length > 0}
<span class="section-count">{workers.length}</span>
{/if}
</div>
{#if loading}
<p class="section-state">Checking workers…</p>
{:else if error}
<p class="section-state error">{error}</p>
{:else if workers.length === 0}
<p class="section-state">{placeholder ?? 'Workers will appear here when an API is connected.'}</p>
{:else}
<ul class="nav-list" aria-label="Workers">
{#each workers as worker (worker.id)}
<li class="nav-item">
<span class="item-title">{worker.label}</span>
<span class="item-meta">
{worker.status}{worker.detail ? ` · ${worker.detail}` : ''}
</span>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.nav-section {
display: grid;
gap: 10px;
}
.section-heading-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
h2 {
margin: 0;
color: #94a3b8;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.section-count {
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 999px;
color: #cbd5e1;
font-size: 0.72rem;
line-height: 1;
padding: 4px 8px;
}
.nav-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.nav-item {
display: grid;
gap: 3px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 14px;
background: rgba(15, 23, 42, 0.64);
padding: 10px 12px;
min-width: 0;
}
.item-title,
.item-meta {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-title {
color: #e2e8f0;
font-weight: 700;
}
.item-meta,
.section-state {
color: #94a3b8;
font-size: 0.78rem;
}
.section-state {
margin: 0;
border: 1px dashed rgba(148, 163, 184, 0.2);
border-radius: 14px;
padding: 10px 12px;
line-height: 1.45;
}
.section-state.error {
border-color: rgba(248, 113, 113, 0.36);
color: #fecaca;
}
</style>

View File

@ -0,0 +1,124 @@
<script lang="ts">
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte';
import type { WorkspaceResponse } from './types';
type Props = {
workspace: WorkspaceResponse | null;
workspaceError?: string | null;
};
let { workspace, workspaceError = null }: Props = $props();
</script>
<aside class="workspace-sidebar" aria-label="Workspace navigation">
<header class="sidebar-header">
<div class="workspace-label">
<span class="eyebrow">workspace</span>
<h1>{workspace?.display_name ?? 'Yoi workspace'}</h1>
{#if workspaceError}
<p class="workspace-status error">Workspace summary unavailable.</p>
{:else if workspace}
<p class="workspace-status">{workspace.workspace_id}</p>
{:else}
<p class="workspace-status">Loading workspace…</p>
{/if}
</div>
<button
class="settings-button"
type="button"
aria-label="Workspace settings"
title="Workspace settings placeholder"
disabled
>
</button>
</header>
<nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection {workspace} />
<ObjectivesNavSection />
<WorkersNavSection />
</nav>
</aside>
<style>
.workspace-sidebar {
align-self: stretch;
min-width: 0;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 26px;
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.96), rgba(15, 23, 42, 0.82)),
rgba(15, 23, 42, 0.88);
box-shadow: 0 24px 80px rgba(2, 6, 23, 0.28);
padding: 18px;
}
.sidebar-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 22px;
min-width: 0;
}
.workspace-label {
display: grid;
gap: 5px;
min-width: 0;
}
.eyebrow {
color: #38bdf8;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
h1,
.workspace-status {
overflow-wrap: anywhere;
}
h1 {
margin: 0;
color: #f8fafc;
font-size: 1.1rem;
line-height: 1.2;
}
.workspace-status {
margin: 0;
color: #94a3b8;
font-size: 0.78rem;
line-height: 1.35;
}
.workspace-status.error {
color: #fecaca;
}
.settings-button {
flex: 0 0 auto;
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 12px;
background: rgba(15, 23, 42, 0.7);
color: #cbd5e1;
cursor: not-allowed;
}
.sidebar-sections {
display: grid;
gap: 24px;
min-width: 0;
}
</style>

View File

@ -0,0 +1,38 @@
export type WorkspaceResponse = {
workspace_id: string;
display_name: string;
record_authority: string;
extension_points: {
event_stream: { status: string; note: string };
runner_connection: { status: string; note: string };
};
};
export type ObjectiveSummary = {
id: string;
title: string;
state: string;
updated_at?: string | null;
linked_tickets?: string[];
record_source?: string;
};
export type InvalidProjectRecord = {
label: string;
reason: string;
};
export type ObjectiveListResponse = {
workspace_id: string;
limit: number;
items: ObjectiveSummary[];
invalid_records: InvalidProjectRecord[];
record_authority: string;
};
export type WorkerSummary = {
id: string;
label: string;
status: string;
detail?: string | null;
};

View File

@ -1,13 +1,6 @@
<script lang="ts"> <script lang="ts">
type WorkspaceResponse = { import WorkspaceSidebar from '$lib/workspace-sidebar/WorkspaceSidebar.svelte';
workspace_id: string; import type { WorkspaceResponse } from '$lib/workspace-sidebar/types';
display_name: string;
record_authority: string;
extension_points: {
event_stream: { status: string; note: string };
runner_connection: { status: string; note: string };
};
};
const endpoints = [ const endpoints = [
{ label: 'Workspace', path: '/api/workspace' }, { label: 'Workspace', path: '/api/workspace' },
@ -21,6 +14,7 @@
let loadError = $state<string | null>(null); let loadError = $state<string | null>(null);
async function loadWorkspace() { async function loadWorkspace() {
loadError = null;
try { try {
const response = await fetch('/api/workspace'); const response = await fetch('/api/workspace');
if (!response.ok) { if (!response.ok) {
@ -45,7 +39,10 @@
/> />
</svelte:head> </svelte:head>
<main class="shell"> <div class="workspace-layout">
<WorkspaceSidebar {workspace} workspaceError={loadError} />
<main class="shell">
<section class="hero"> <section class="hero">
<p class="eyebrow">Local / single-workspace bootstrap</p> <p class="eyebrow">Local / single-workspace bootstrap</p>
<h1>Yoi Workspace Control Plane</h1> <h1>Yoi Workspace Control Plane</h1>
@ -99,9 +96,14 @@
</p> </p>
</div> </div>
</section> </section>
</main> </main>
</div>
<style> <style>
:global(*) {
box-sizing: border-box;
}
:global(body) { :global(body) {
margin: 0; margin: 0;
background: #0f172a; background: #0f172a;
@ -110,14 +112,28 @@
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
} }
.shell { .workspace-layout {
width: min(980px, calc(100vw - 32px)); display: grid;
grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
gap: 24px;
width: min(1180px, calc(100vw - 32px));
margin: 0 auto; margin: 0 auto;
padding: 48px 0; padding: 32px 0;
min-width: 0;
}
.shell {
display: grid;
gap: 24px;
min-width: 0;
} }
.hero { .hero {
margin-bottom: 24px; min-width: 0;
}
.hero p {
max-width: 68ch;
} }
.eyebrow { .eyebrow {
@ -131,12 +147,19 @@
margin: 0 0 16px; margin: 0 0 16px;
font-size: clamp(2.5rem, 8vw, 5rem); font-size: clamp(2.5rem, 8vw, 5rem);
line-height: 0.95; line-height: 0.95;
overflow-wrap: anywhere;
} }
h2 { h2 {
margin-top: 0; margin-top: 0;
} }
p,
li,
dd {
overflow-wrap: anywhere;
}
code { code {
color: #bae6fd; color: #bae6fd;
} }
@ -144,7 +167,8 @@
.grid { .grid {
display: grid; display: grid;
gap: 16px; gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(min(260px, 100%), 1fr));
min-width: 0;
} }
.card { .card {
@ -153,6 +177,7 @@
background: rgba(15, 23, 42, 0.75); background: rgba(15, 23, 42, 0.75);
padding: 24px; padding: 24px;
box-shadow: 0 24px 80px rgba(15, 23, 42, 0.35); box-shadow: 0 24px 80px rgba(15, 23, 42, 0.35);
min-width: 0;
} }
dl { dl {
@ -173,4 +198,17 @@
.error { .error {
color: #fca5a5; color: #fca5a5;
} }
@media (max-width: 760px) {
.workspace-layout {
grid-template-columns: 1fr;
width: min(100vw - 24px, 620px);
gap: 18px;
padding: 18px 0;
}
.card {
padding: 18px;
}
}
</style> </style>