diff --git a/Dockerfile b/Dockerfile index f45a9e3..b8ea55c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -64,15 +64,20 @@ COPY --from=builder /app/vendor ./vendor COPY pieces ./pieces COPY docs ./docs -# Ship a runnable default while still allowing a read-only config mount. +# Ship a runnable default while still allowing a config bind-mount. COPY config.yaml.example ./config.yaml -RUN mkdir -p /data /workspaces \ - && chown -R node:node /data /workspaces +# The app runs as the non-root `node` user and writes its state under ./data +# (db, users, skills, secrets) — relative to WORKDIR /app, i.e. /app/data — plus +# /workspaces (worktree) and config.yaml (Settings save). Create and own those +# so a fresh deploy doesn't hit EACCES. /app/data and /workspaces are the volume +# mount points in docker-compose. +RUN mkdir -p /app/data /workspaces \ + && chown -R node:node /app/data /workspaces config.yaml ENV NODE_ENV=production \ PORT=9876 \ - DB_PATH=/data/maestro.db + DB_PATH=/app/data/maestro.db EXPOSE 9876 diff --git a/docker-compose.yml b/docker-compose.yml index f01721f..68012ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,15 +16,17 @@ services: environment: - NODE_ENV=production - PORT=9876 - - DB_PATH=/data/maestro.db + - DB_PATH=/app/data/maestro.db - WORKTREE_DIR=/workspaces volumes: - # SQLite DB 永続化 - - maestro-data:/data + # アプリの状態 (DB / users / skills / secrets) を永続化。 + # アプリは WORKDIR /app からの相対 ./data に書くので /app/data にマウントする。 + - maestro-data:/app/data # エージェントワークスペース永続化 - maestro-workspaces:/workspaces - # 設定ファイル (任意でホストから read-only マウント) - # - ./config.yaml:/app/config.yaml:ro + # config.yaml をホストから永続化したい場合は bind-mount (書き込み可)。 + # Settings UI / npm run setup で書き換えるなら :ro は付けないこと。 + # - ./config.yaml:/app/config.yaml healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:9876/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] interval: 30s diff --git a/ui/src/components/chat/ChatMessage.tsx b/ui/src/components/chat/ChatMessage.tsx index 557e2a9..b2ce88f 100644 --- a/ui/src/components/chat/ChatMessage.tsx +++ b/ui/src/components/chat/ChatMessage.tsx @@ -392,7 +392,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }:
{author} · {new Date(createdAt).toLocaleString()}
-
+
diff --git a/ui/src/components/files/FilePreview.tsx b/ui/src/components/files/FilePreview.tsx index f352ff8..6ccb96c 100644 --- a/ui/src/components/files/FilePreview.tsx +++ b/ui/src/components/files/FilePreview.tsx @@ -328,7 +328,7 @@ export function MarkdownPreview({ content, imageBaseUrl, taskId, showOutline = f }; const content_el = ( -
+
{segments.map((seg, i) => { if (seg.type === 'embed') { return ; diff --git a/ui/src/components/layout/ThemeToggle.tsx b/ui/src/components/layout/ThemeToggle.tsx new file mode 100644 index 0000000..278fa81 --- /dev/null +++ b/ui/src/components/layout/ThemeToggle.tsx @@ -0,0 +1,92 @@ +import { useState } from 'react'; +import { type ThemePref, readStoredTheme, setThemePref } from '../../lib/theme'; + +const ICON_PROPS = { + width: 14, + height: 14, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 1.8, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + 'aria-hidden': true, +}; + +const OPTIONS: Array<{ value: ThemePref; label: string; icon: JSX.Element }> = [ + { + value: 'system', + label: 'システム設定に合わせる', + icon: ( + + + + + ), + }, + { + value: 'light', + label: 'ライト', + icon: ( + + + + + ), + }, + { + value: 'dark', + label: 'ダーク', + icon: ( + + + + ), + }, +]; + +/** + * Compact 3-way theme switch (system / light / dark) for the TopBar. + * + * All theme logic lives in lib/theme.ts: this only tracks the displayed + * preference and calls setThemePref (persist + apply) on selection. The + * pre-paint inline script in index.html + initTheme() keep the actual + * [data-theme] attribute correct on load and on OS changes. + */ +export function ThemeToggle() { + const [pref, setPref] = useState(() => readStoredTheme()); + + const choose = (value: ThemePref) => { + setPref(value); + setThemePref(value); + }; + + return ( +
+ {OPTIONS.map((opt) => { + const active = pref === opt.value; + return ( + + ); + })} +
+ ); +} diff --git a/ui/src/components/layout/TopBar.tsx b/ui/src/components/layout/TopBar.tsx index 40c5299..1aa92c1 100644 --- a/ui/src/components/layout/TopBar.tsx +++ b/ui/src/components/layout/TopBar.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import type { PageId } from '../../lib/urlState'; import type { AuthUser } from '../../App'; +import { ThemeToggle } from './ThemeToggle'; interface TopBarProps { currentPage: PageId; @@ -137,6 +138,7 @@ export function TopBar({
+ {user && (
diff --git a/ui/src/index.css b/ui/src/index.css index 5f24f93..2279f51 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -729,3 +729,37 @@ } .mdxg-outline a.depth-2 { padding-left: 1.25rem; } .mdxg-outline a.depth-3 { padding-left: 2rem; font-size: 0.7rem; } + +/* ── Dark mode overrides for the MD reader + outline ─────────────────── + `.mdxg-reader pre` (code blocks) are github-dark already and stay as-is. + Body text comes from `prose prose-slate` whose slate scale is var-remapped + (auto light in dark). These override only the hardcoded light-mode chrome + (#18181b headings, light table/blockquote fills, pink inline code, the + outline's light hover/active) that would otherwise be dark-on-dark or a + light island in dark mode. */ +[data-theme="dark"] .mdxg-reader h1, +[data-theme="dark"] .mdxg-reader h2, +[data-theme="dark"] .mdxg-reader h3, +[data-theme="dark"] .mdxg-reader h4, +[data-theme="dark"] .mdxg-reader h5, +[data-theme="dark"] .mdxg-reader h6, +[data-theme="dark"] .mdxg-reader strong, +[data-theme="dark"] .mdxg-reader b { color: var(--ink); } +[data-theme="dark"] .mdxg-reader h1 { border-bottom-color: var(--slate-700); } +[data-theme="dark"] .mdxg-reader h2 { border-left-color: var(--slate-700); } +[data-theme="dark"] .mdxg-reader h3::before { background: var(--slate-700); } +[data-theme="dark"] .mdxg-reader h1 .mdxg-anchor, +[data-theme="dark"] .mdxg-reader h2 .mdxg-anchor, +[data-theme="dark"] .mdxg-reader h3 .mdxg-anchor { color: var(--slate-500); } +[data-theme="dark"] .mdxg-reader thead th { background: var(--surface-2); border-bottom-color: var(--hairline); } +[data-theme="dark"] .mdxg-reader tbody td { border-bottom-color: var(--hairline); } +[data-theme="dark"] .mdxg-reader tbody tr:nth-child(even) { background: var(--surface); } +[data-theme="dark"] .mdxg-reader blockquote { background: var(--surface); color: var(--muted); } +[data-theme="dark"] .mdxg-reader :not(pre) > code { + background: var(--surface-2); + color: #f3a4c0; + border-color: var(--hairline); +} +[data-theme="dark"] .mdxg-outline a { color: var(--muted); } +[data-theme="dark"] .mdxg-outline a:hover { background: var(--surface-2); color: var(--ink); } +[data-theme="dark"] .mdxg-outline a.active { background: var(--surface-2); color: var(--ink); } diff --git a/ui/src/lib/markdown-text.tsx b/ui/src/lib/markdown-text.tsx index 49edf5f..88a56a9 100644 --- a/ui/src/lib/markdown-text.tsx +++ b/ui/src/lib/markdown-text.tsx @@ -57,7 +57,7 @@ const md = new Marked({ gfm: true, breaks: true, renderer }); // remove any doubt we ALSO use Tailwind's `!` important prefix. // Result: paragraphs ~4px apart, lines tight, headings reasonable. const COMPACT_PROSE = [ - 'prose prose-sm prose-slate max-w-none break-words min-w-0', + 'prose prose-sm prose-slate dark:prose-invert max-w-none break-words min-w-0', // overflow-wrap: anywhere ensures unbreakable strings (long URLs, hashes, // file paths) wrap mid-token. `break-words` alone only honors word // boundaries which leaves them poking out of the container. diff --git a/ui/src/lib/theme.test.ts b/ui/src/lib/theme.test.ts index 0cf3105..e0f6514 100644 --- a/ui/src/lib/theme.test.ts +++ b/ui/src/lib/theme.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { resolveTheme, isThemePref, readStoredTheme, writeStoredTheme, THEME_STORAGE_KEY } from './theme'; +import { resolveTheme, isThemePref, readStoredTheme, writeStoredTheme, systemPrefersDark, THEME_STORAGE_KEY } from './theme'; describe('resolveTheme', () => { it('explicit dark/light win regardless of system', () => { @@ -65,3 +65,13 @@ describe('writeStoredTheme', () => { expect(() => writeStoredTheme('dark')).not.toThrow(); }); }); + +describe('systemPrefersDark', () => { + afterEach(() => vi.unstubAllGlobals()); + it('reflects the OS matchMedia result', () => { + vi.stubGlobal('window', { matchMedia: () => ({ matches: true }) }); + expect(systemPrefersDark()).toBe(true); + vi.stubGlobal('window', { matchMedia: () => ({ matches: false }) }); + expect(systemPrefersDark()).toBe(false); + }); +}); diff --git a/ui/src/lib/theme.ts b/ui/src/lib/theme.ts index 66fd551..3a195e9 100644 --- a/ui/src/lib/theme.ts +++ b/ui/src/lib/theme.ts @@ -38,6 +38,20 @@ export function applyTheme(root: HTMLElement, resolved: ResolvedTheme): void { const DARK_MQ = '(prefers-color-scheme: dark)'; +/** True if the OS currently prefers a dark color scheme. */ +export function systemPrefersDark(): boolean { + return typeof window !== 'undefined' && window.matchMedia(DARK_MQ).matches; +} + +/** + * Persist a new preference and apply it to the document immediately. Used by + * the theme toggle UI. The pure resolution lives in resolveTheme. + */ +export function setThemePref(pref: ThemePref): void { + writeStoredTheme(pref); + applyTheme(document.documentElement, resolveTheme(pref, systemPrefersDark())); +} + /** * Wire runtime theme. The INITIAL attribute is set by the inline script in * index.html (pre-paint, no FOUC); this keeps it live and re-applies when the diff --git a/ui/src/pages/HelpPage.tsx b/ui/src/pages/HelpPage.tsx index 82c7a63..d891569 100644 --- a/ui/src/pages/HelpPage.tsx +++ b/ui/src/pages/HelpPage.tsx @@ -124,7 +124,7 @@ export function HelpPage({ isAdmin, onAskAi, selectedId, onSelect }: HelpPagePro
diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js index 2634484..0ac8b18 100644 --- a/ui/tailwind.config.js +++ b/ui/tailwind.config.js @@ -12,6 +12,10 @@ import typography from '@tailwindcss/typography'; /** @type {import('tailwindcss').Config} */ export default { content: ['./index.html', './src/**/*.{ts,tsx}'], + // Drive the `dark:` variant off our runtime [data-theme="dark"] attribute + // (set by index.html's pre-paint script + lib/theme.ts), not the OS media + // query — so `dark:prose-invert` follows the in-app theme toggle. + darkMode: ['selector', '[data-theme="dark"]'], theme: { extend: { colors: {