feat: add settings admin shell

This commit is contained in:
Keisuke Hirata 2026-07-02 23:34:30 +09:00
parent bcf71f588d
commit c0c6880b1a
No known key found for this signature in database
7 changed files with 498 additions and 7 deletions

View File

@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts",
"test": "deno test --allow-read=src src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},

View File

@ -988,3 +988,155 @@
text-align: left;
}
}
.settings-button {
text-decoration: none;
cursor: pointer;
}
.settings-button:hover,
.settings-button:focus-visible,
.settings-button.active {
background: var(--interactive-selected);
color: var(--accent);
}
.settings-shell {
gap: var(--space-5);
}
.settings-hero,
.settings-notice,
.settings-section-header,
.settings-patterns {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
}
.hero-copy {
max-width: 54rem;
margin: 0;
color: var(--text-muted);
}
.badge {
flex: 0 0 auto;
border-radius: 999px;
padding: 0.35rem 0.65rem;
background: var(--bg-subtle);
color: var(--text-muted);
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.badge.warning {
color: var(--warning);
}
.badge.success {
color: var(--success);
}
.settings-notice {
border-left: 4px solid var(--warning);
padding-left: var(--space-4);
}
.settings-diagnostic {
display: grid;
gap: var(--space-1);
max-width: 24rem;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: var(--text-muted);
}
.settings-diagnostic strong {
color: var(--text-strong);
}
.settings-nav-card {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.settings-nav-link {
display: grid;
gap: var(--space-1);
min-width: min(16rem, 100%);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--line);
border-radius: var(--radius-panel);
background: var(--bg-raised);
color: inherit;
text-decoration: none;
}
.settings-nav-link:hover,
.settings-nav-link:focus-visible {
background: var(--interactive-hover);
}
.settings-nav-link span {
color: var(--text-strong);
font-weight: 800;
}
.settings-grid,
.settings-pattern-grid {
grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
}
.settings-section,
.settings-patterns,
.settings-pattern {
display: grid;
gap: var(--space-4);
}
.settings-section ul,
.settings-patterns ul {
margin: 0;
padding-left: 1.2rem;
}
.settings-section li + li {
margin-top: var(--space-2);
}
.settings-identity-list {
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.settings-pattern {
padding: var(--space-4);
border-radius: var(--radius-panel);
background: var(--bg-raised);
}
.settings-pattern h3,
.settings-pattern p {
margin: 0;
}
.status-message {
margin: 0;
color: var(--text-muted);
}
@media (max-width: 760px) {
.settings-hero,
.settings-notice,
.settings-section-header,
.settings-patterns {
display: grid;
}
}

View File

@ -0,0 +1,153 @@
<script lang="ts">
import WorkspaceSidebar from "$lib/workspace-sidebar/WorkspaceSidebar.svelte";
import type { WorkspaceResponse } from "$lib/workspace-sidebar/types";
import {
SETTINGS_PATTERNS,
SETTINGS_PERMISSION_NOTICE,
SETTINGS_SECTIONS,
settingsSectionHref,
} from "./model";
let workspace = $state<WorkspaceResponse | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
$effect(() => {
let cancelled = false;
async function loadWorkspace() {
loading = true;
error = null;
try {
const response = await fetch("/api/workspace");
if (!response.ok) {
throw new Error(`workspace request failed (${response.status})`);
}
const data = (await response.json()) as WorkspaceResponse;
if (!cancelled) {
workspace = data;
}
} catch (err) {
if (!cancelled) {
error = err instanceof Error ? err.message : "workspace request failed";
}
} finally {
if (!cancelled) {
loading = false;
}
}
}
loadWorkspace();
return () => {
cancelled = true;
};
});
</script>
<svelte:head>
<title>Settings · Yoi Workspace</title>
</svelte:head>
<div class="workspace-layout">
<WorkspaceSidebar workspace={workspace} currentPath="/settings" />
<main class="shell settings-shell" aria-labelledby="settings-title">
<section class="hero settings-hero">
<div>
<p class="eyebrow">Workspace Browser</p>
<h1 id="settings-title">Settings / Admin</h1>
<p class="hero-copy">
Read-only shell for future local administration surfaces. This page creates
navigation and operator context without adding mutation authority.
</p>
</div>
<span class="badge warning">shell only</span>
</section>
<section class="card settings-notice" aria-labelledby="settings-boundary-title">
<div>
<p class="eyebrow">Authority boundary</p>
<h2 id="settings-boundary-title">No browser admin permission model</h2>
<p>{SETTINGS_PERMISSION_NOTICE}</p>
</div>
<div class="settings-diagnostic" role="note">
<strong>Diagnostic pattern</strong>
<span>Future controls must use typed Backend diagnostics and restart-required states.</span>
</div>
</section>
<section class="settings-nav-card" aria-label="Settings sections">
{#each SETTINGS_SECTIONS as section}
<a class="settings-nav-link" href={settingsSectionHref(section.id)}>
<span>{section.label}</span>
<small>{section.status === "read-only" ? "Read-only" : "Placeholder"}</small>
</a>
{/each}
</section>
<div class="grid settings-grid">
{#each SETTINGS_SECTIONS as section}
<section class="card settings-section" id={section.id} aria-labelledby={`${section.id}-title`}>
<header class="settings-section-header">
<div>
<p class="eyebrow">{section.status}</p>
<h2 id={`${section.id}-title`}>{section.label}</h2>
</div>
{#if section.status === "placeholder"}
<span class="badge neutral">not implemented</span>
{:else}
<span class="badge success">read-only</span>
{/if}
</header>
<p>{section.summary}</p>
<ul>
{#each section.bullets as bullet}
<li>{bullet}</li>
{/each}
</ul>
{#if section.id === "workspace-identity"}
<dl class="settings-identity-list">
<div>
<dt>Workspace id</dt>
<dd><code>{workspace?.workspace_id ?? "loading"}</code></dd>
</div>
<div>
<dt>Display name</dt>
<dd>{workspace?.display_name ?? "loading"}</dd>
</div>
<div>
<dt>Record authority</dt>
<dd>.yoi tickets/objectives through the Backend projection</dd>
</div>
</dl>
{/if}
</section>
{/each}
</div>
<section class="card settings-patterns" aria-labelledby="settings-patterns-title">
<div>
<p class="eyebrow">Implementation patterns</p>
<h2 id="settings-patterns-title">How future settings should appear</h2>
</div>
<div class="grid settings-pattern-grid">
{#each SETTINGS_PATTERNS as pattern}
<article class="settings-pattern">
<h3>{pattern.title}</h3>
<p>{pattern.body}</p>
</article>
{/each}
</div>
</section>
{#if loading}
<p class="status-message">Loading workspace summary…</p>
{:else if error}
<p class="status-message error">Workspace summary unavailable: {error}</p>
{/if}
</main>
</div>

View File

@ -0,0 +1,82 @@
import {
SETTINGS_PATTERNS,
SETTINGS_PERMISSION_NOTICE,
SETTINGS_ROUTE,
SETTINGS_SECTIONS,
settingsSectionHref,
} from "./model.ts";
declare const Deno: {
test(name: string, fn: () => void): void;
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
Deno.test("settings section navigation stays under the settings route", () => {
assert(SETTINGS_ROUTE === "/settings", "settings route should be stable");
for (const section of SETTINGS_SECTIONS) {
const href = settingsSectionHref(section.id);
assert(
href.startsWith("/settings#"),
`${section.id} should link under settings`,
);
assert(
href.endsWith(section.id),
`${section.id} href should preserve section id`,
);
}
});
Deno.test("settings shell advertises no fake browser admin model", () => {
assert(
SETTINGS_PERMISSION_NOTICE.includes("no browser user, role, permission"),
"notice should explicitly deny a browser permission model",
);
assert(
SETTINGS_PERMISSION_NOTICE.includes("does not create an admin role"),
"notice should not imply an admin role exists",
);
});
Deno.test("settings placeholders avoid mutation promises and raw authority leaks", () => {
const allText = [
SETTINGS_PERMISSION_NOTICE,
...SETTINGS_SECTIONS.flatMap((section) => [
section.label,
section.summary,
...section.bullets,
]),
...SETTINGS_PATTERNS.flatMap((pattern) => [pattern.title, pattern.body]),
].join("\n");
assert(
allText.includes(
"does not add, remove, test, or persist Runtime endpoints",
),
"Runtime Connections should remain a placeholder",
);
assert(
allText.includes("Restart-required"),
"restart-required pattern should be visible",
);
for (
const forbidden of [
"/home/",
"socket path:",
"token:",
"secret:",
"store root:",
]
) {
assert(
!allText.includes(forbidden),
`settings copy should not expose ${forbidden}`,
);
}
});

View File

@ -0,0 +1,83 @@
export type SettingsSectionId =
| "runtime-connections"
| "backend-config"
| "workspace-identity";
export type SettingsSection = {
readonly id: SettingsSectionId;
readonly label: string;
readonly status: "placeholder" | "read-only";
readonly summary: string;
readonly bullets: readonly string[];
};
export type SettingsPattern = {
readonly title: string;
readonly body: string;
};
export const SETTINGS_ROUTE = "/settings";
export const SETTINGS_PERMISSION_NOTICE =
"Yoi currently has no browser user, role, permission, or multi-user authorization model. This shell is intentionally local and descriptive; it does not create an admin role or grant mutation authority.";
export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
{
id: "runtime-connections",
label: "Runtime Connections",
status: "placeholder",
summary:
"Future Runtime connection management will live here. The current view does not add, remove, test, or persist Runtime endpoints.",
bullets: [
"Shows where connection diagnostics will surface without exposing tokens, sockets, store roots, or raw endpoint secrets.",
"Connection changes require a later typed Backend API and are not performed by this shell.",
"Restart-required states should be shown as bounded diagnostics rather than live mutation controls.",
],
},
{
id: "backend-config",
label: "Backend Config",
status: "placeholder",
summary:
"Configuration inspection is planned, but editing Backend config or secrets is out of scope for this shell.",
bullets: [
"Only sanitized summaries belong in the browser; raw config paths, secret refs, tokens, and store roots stay backend-side.",
"Missing-provider or invalid-config states should be displayed as typed diagnostics.",
"No fake permission model is created to make config editing appear available.",
],
},
{
id: "workspace-identity",
label: "Workspace Identity",
status: "read-only",
summary:
"Workspace identity is presented as read-only context so operators can tell which workspace the browser is attached to.",
bullets: [
"Use opaque workspace ids and display names rather than raw filesystem paths.",
"Repository/project-record authority remains backend-side and is not edited here.",
"Identity changes need a later explicit migration flow.",
],
},
];
export const SETTINGS_PATTERNS: readonly SettingsPattern[] = [
{
title: "Sanitized diagnostics",
body:
"Settings cards should show bounded codes and operator-facing messages, not raw socket paths, credentials, secret refs, token values, or Runtime store paths.",
},
{
title: "Restart-required changes",
body:
"When a future setting cannot apply live, the browser should say restart required and leave the mutation to a typed Backend workflow.",
},
{
title: "Read-only until typed APIs exist",
body:
"Placeholder sections describe planned surfaces without pretending that user, role, permission, or Runtime mutation APIs already exist.",
},
];
export function settingsSectionHref(id: SettingsSectionId): string {
return `${SETTINGS_ROUTE}#${id}`;
}

View File

@ -11,6 +11,7 @@
};
let { workspace, workspaceError = null, currentPath = '/' }: Props = $props();
let settingsActive = $derived(currentPath.startsWith("/settings"));
</script>
<aside class="workspace-sidebar" aria-label="Workspace navigation">
@ -29,20 +30,35 @@
{/if}
</div>
<button
<a
class="settings-button"
type="button"
aria-label="Workspace settings"
title="Workspace settings placeholder"
disabled
class:active={settingsActive}
href="/settings"
aria-label="Open Settings / Admin"
title="Settings / Admin"
aria-current={settingsActive ? 'page' : undefined}
>
</button>
</a>
</header>
<nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection {workspace} {currentPath} />
<ObjectivesNavSection {currentPath} />
<WorkersNavSection {currentPath} />
<section class="nav-section" aria-labelledby="settings-heading">
<div class="section-heading-row">
<h2 id="settings-heading">settings</h2>
</div>
<ul class="nav-list" aria-label="Settings">
<li>
<a class="nav-item" class:active={settingsActive} href="/settings" aria-current={settingsActive ? 'page' : undefined}>
<span class="item-title">Settings / Admin</span>
<span class="item-meta">Backend shell and diagnostics</span>
</a>
</li>
</ul>
</section>
</nav>
</aside>

View File

@ -0,0 +1,5 @@
<script lang="ts">
import SettingsPage from "$lib/workspace-settings/SettingsPage.svelte";
</script>
<SettingsPage />