Compare commits
155
Commits
b1674169a0
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
841506b10c | ||
|
|
c34454b840 | ||
|
|
bd60d679d9 | ||
|
|
174ec8af46 | ||
|
|
4852b0d3df | ||
|
|
22f907c44d | ||
|
|
5795fa908a | ||
|
|
be3a0ffaf9 | ||
|
|
5f8b824523 | ||
|
|
3fc693443e | ||
|
|
3d6931b838 | ||
|
|
c7cf07824a | ||
|
|
67fdf7c55b | ||
|
|
37616682f0 | ||
|
|
97b93bcaea | ||
|
|
f491b6d7b9 | ||
|
|
1a959b1db0 | ||
|
|
6d01788326 | ||
|
|
17d59fa3e0 | ||
|
|
6a6a388ecd | ||
|
|
2cc9e1380e | ||
|
|
84541f392b | ||
|
|
da2383b079 | ||
|
|
f8522fb28f | ||
|
|
5c51e29f26 | ||
|
|
4c9a637507 | ||
|
|
731ca284c3 | ||
|
|
1e139d0d18 | ||
|
|
6eedb0f6b9 | ||
|
|
378a4b8f99 | ||
|
|
67a529a38e | ||
|
|
473280e690 | ||
|
|
1fde646c60 | ||
|
|
23a385e2db | ||
|
|
8eef9e7845 | ||
|
|
15d2e0c682 | ||
|
|
0f80b790c7 | ||
|
|
46d88f9e7e | ||
|
|
b0a7d9e89b | ||
|
|
4dc57f8ab2 | ||
|
|
3da40abcb8 | ||
|
|
78489308b8 | ||
|
|
0937b1761e | ||
|
|
143b968c5d | ||
|
|
839d6902ad | ||
|
|
03b3998585 | ||
|
|
4ca3ab02c4 | ||
|
|
d3ee24f239 | ||
|
|
3c0192d749 | ||
|
|
61e6e38419 | ||
|
|
293a14361a | ||
|
|
6ca215d2b6 | ||
|
|
390846a3d5 | ||
|
|
57781770c3 | ||
|
|
1fb883b72f | ||
|
|
05b64ccacb | ||
|
|
b0a0bf8abd | ||
|
|
dd878bc498 | ||
|
|
1feef179d2 | ||
|
|
51e5c22818 | ||
|
|
21cb9ee537 | ||
|
|
fe63f3985b | ||
|
|
6db791965f | ||
|
|
84c5c0dc81 | ||
|
|
35f3f09d12 | ||
|
|
2b85443dd1 | ||
|
|
c569cdd0eb | ||
|
|
1b81310847 | ||
|
|
a47e1b9c4e | ||
|
|
8bab0c762b | ||
|
|
ef7ebddadf | ||
|
|
aa850ac1e1 | ||
|
|
e0328f2b1c | ||
|
|
083944f0fd | ||
|
|
dd1b5e5ddb | ||
|
|
0d396de215 | ||
|
|
9fa474b3c4 | ||
|
|
8977bed28d | ||
|
|
fc9c8119a2 | ||
|
|
cca40affe4 | ||
|
|
4b5d9005fd | ||
|
|
3ffdbbc521 | ||
|
|
e9cd3435ad | ||
|
|
ef1dbdfc86 | ||
|
|
8b069cc93f | ||
|
|
04b58ce01e | ||
|
|
b7d6fc7e5d | ||
|
|
b33e2a79c6 | ||
|
|
7a81b7333e | ||
|
|
85625de394 | ||
|
|
eb400a521b | ||
|
|
d257c0fd67 | ||
|
|
0ea3bff797 | ||
|
|
1942913366 | ||
|
|
d2f08d6113 | ||
|
|
ab9b5b647c | ||
|
|
12f71fabdf | ||
|
|
dd60244429 | ||
|
|
b49efdcb11 | ||
|
|
f6629d4bcf | ||
|
|
2bc054d7ef | ||
|
|
eec241d3c0 | ||
|
|
8126db3203 | ||
|
|
807f478cac | ||
|
|
513019786e | ||
|
|
5fbee18a94 | ||
|
|
4f31890bde | ||
|
|
2ad2e943f3 | ||
|
|
e6c89f6b88 | ||
|
|
96079d5b77 | ||
|
|
c4553d937c | ||
|
|
ee02ed37c1 | ||
|
|
dd1fc2d506 | ||
|
|
5102ffb558 | ||
|
|
322cbc3b5e | ||
|
|
33f9a0a4d4 | ||
|
|
e8d6de287b | ||
|
|
f7742173aa | ||
|
|
1f6c592226 | ||
|
|
c972af2690 | ||
|
|
b6d01686d8 | ||
|
|
94d32c524a | ||
|
|
79040fe5db | ||
|
|
9293a9b198 | ||
|
|
4da29562bd | ||
|
|
4f22646c88 | ||
|
|
85e0501705 | ||
|
|
b91c447b0b | ||
|
|
88106309f4 | ||
|
|
d99c4e3c15 | ||
|
|
c0b9831b0f | ||
|
|
700cfc664b | ||
|
|
f125f86e16 | ||
|
|
95e1d47ceb | ||
|
|
f2ab02ba2b | ||
|
|
d11eb9145b | ||
|
|
e631a4e615 | ||
|
|
9eddb8497c | ||
|
|
974fa6a215 | ||
|
|
da511fcc9f | ||
|
|
79aec4f9c1 | ||
|
|
2f9c9b0e0b | ||
|
|
4c8044826f | ||
|
|
50b6902fe2 | ||
|
|
2a5f5fdbe1 | ||
|
|
1983e8f4b1 | ||
|
|
30826d250c | ||
|
|
2c36249c15 | ||
|
|
8fe571e175 | ||
|
|
6cc47c7222 | ||
|
|
651119cfb0 | ||
|
|
3a5835b109 | ||
|
|
ff91948fa3 | ||
|
|
559f70eef0 | ||
|
|
1d82e81527 |
+169
-14
@@ -40,11 +40,23 @@ on:
|
||||
- "frontend/**"
|
||||
- "tests/**"
|
||||
- "pyproject.toml"
|
||||
# The lock now determines what gets installed, so a lock-only change has
|
||||
# to trigger a run — otherwise a dependency bump lands untested.
|
||||
- "uv.lock"
|
||||
- "alembic/**"
|
||||
- "alembic.ini"
|
||||
- "Dockerfile"
|
||||
- "assets/**"
|
||||
- "fable-mcp/**"
|
||||
# The plugin ships straight from this repo — installs fetch it via
|
||||
# .claude-plugin/marketplace.json, NOT from the image. So a push here is
|
||||
# the release, with no build step in between. Omitting these paths meant
|
||||
# plugin changes triggered no workflow at all, which is how #2198's three
|
||||
# broken hooks and then #2209's missing version bump both reached a live
|
||||
# install. See the `plugin` job below.
|
||||
- "plugin/**"
|
||||
- ".claude-plugin/**"
|
||||
- "scripts/check_plugin.py"
|
||||
- ".forgejo/workflows/ci.yml"
|
||||
# Manual trigger from the Forgejo Actions UI. Useful when an image has
|
||||
# been built but the deployment didn't pick it up, or when re-running
|
||||
@@ -81,6 +93,11 @@ jobs:
|
||||
|
||||
- name: Cache npm download cache
|
||||
uses: actions/cache@v4
|
||||
# Non-fatal: a transient cache-backend hiccup must NOT fail the whole
|
||||
# typecheck job (it was skipping install + type check and reporting red
|
||||
# on backend-only pushes — see issue task #828). On cache miss/error the
|
||||
# job just installs without the cache.
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-cache-${{ hashFiles('frontend/package-lock.json') }}
|
||||
@@ -94,6 +111,48 @@ jobs:
|
||||
run: npx vue-tsc --noEmit
|
||||
working-directory: frontend
|
||||
|
||||
# Guards the one part of this repo that ships to users without a build step.
|
||||
# See scripts/check_plugin.py for what it checks and, as importantly, what it
|
||||
# can't check yet (shellcheck and jq are absent from ci-python).
|
||||
plugin:
|
||||
name: Plugin hooks
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
steps:
|
||||
# Bare `uses:`, no `with:` block. Adding one made this action fail to
|
||||
# extract on the act_runner ("Cannot find module .../dist/index.js") while
|
||||
# every bare checkout in the same run succeeded — see run 3027. Nothing
|
||||
# here needs `fetch-depth: 0` anyway: the version check diffs two trees,
|
||||
# and a tree diff needs both trees, not a common ancestor. A depth-1 fetch
|
||||
# of main's tip is enough, and cheaper.
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# Per-job, not in the image, per CI-runner's docs/process.md: "If only one
|
||||
# project needs the dep, prefer that project installing it per-job in
|
||||
# their workflow — at least until a second consumer arrives." Scribe is
|
||||
# the only consumer today. Promotion into ci-python is filed as an issue
|
||||
# on CI-runner rather than assumed here.
|
||||
#
|
||||
# jq is not optional for the smoke test: every hook exits at line 1
|
||||
# without it, so the check would pass while exercising nothing.
|
||||
- name: Install shell tooling
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends jq shellcheck
|
||||
|
||||
# On main the comparison would be against itself, so only the syntax and
|
||||
# pattern checks mean anything there.
|
||||
- name: Check plugin hooks and manifest
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
python3 scripts/check_plugin.py --no-version
|
||||
else
|
||||
git fetch --no-tags --depth=1 origin main:refs/remotes/origin/main
|
||||
python3 scripts/check_plugin.py
|
||||
fi
|
||||
|
||||
lint:
|
||||
name: Python lint
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
@@ -106,7 +165,18 @@ jobs:
|
||||
# ruff is pre-installed in the ci-python image — no install
|
||||
# step needed, lint runs in ~2s.
|
||||
- name: Lint
|
||||
run: ruff check src/
|
||||
run: ruff check src/ scripts/
|
||||
|
||||
# Design tokens: does the frontend's CSS agree with the stylesheet the
|
||||
# design system generates? Fails only on an unresolvable var() reference —
|
||||
# that count is at zero, so this is a ratchet rather than a backlog. The
|
||||
# literal findings are printed, not gated; hundreds exist and a
|
||||
# permanently-red job is one nobody reads.
|
||||
#
|
||||
# Stdlib only, no install, no network: the source of truth is theme.css,
|
||||
# which is generated from the design system and committed.
|
||||
- name: Design token check
|
||||
run: python3 scripts/check_design_tokens.py --report-literals
|
||||
|
||||
test:
|
||||
name: Python tests
|
||||
@@ -121,26 +191,111 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: uv-${{ hashFiles('pyproject.toml') }}
|
||||
# Keyed on the LOCK, not pyproject: the lock is what determines the
|
||||
# installed set now, and a pyproject edit that doesn't change
|
||||
# resolution shouldn't throw the cache away.
|
||||
key: uv-${{ hashFiles('uv.lock') }}
|
||||
restore-keys: uv-
|
||||
|
||||
- name: Create virtual environment
|
||||
run: uv venv /opt/venv
|
||||
|
||||
- name: Install package with dev deps
|
||||
run: |
|
||||
# http-ece doesn't declare setuptools as a build dep, and uv
|
||||
# creates bare venvs without it. Install setuptools first so
|
||||
# --no-build-isolation can find it.
|
||||
uv pip install --python /opt/venv/bin/python setuptools wheel
|
||||
uv pip install --python /opt/venv/bin/python --no-build-isolation http-ece
|
||||
uv pip install --python /opt/venv/bin/python -e ".[dev]"
|
||||
# Installs exactly what uv.lock pins, and resolves nothing itself.
|
||||
#
|
||||
# This replaced `uv pip install -e ".[dev]"`, which resolved from the
|
||||
# pyproject constraints and ignored the lock entirely. Every dependency
|
||||
# floated: on 2026-07-28 mcp 2.0.0 shipped mid-session and turned `main`
|
||||
# red with no repo change (issue #2194). Green CI has to mean "these exact
|
||||
# versions passed", or it isn't evidence of anything.
|
||||
#
|
||||
# `--locked` also FAILS when uv.lock is stale against pyproject, so a
|
||||
# dependency edit has to go through a deliberate `uv lock` — it can't
|
||||
# arrive on its own. That check earned its place immediately: it caught
|
||||
# that the lock had been missing `pgvector` entirely (added to pyproject,
|
||||
# never re-locked), which the old install path had been silently papering
|
||||
# over by resolving from pyproject instead.
|
||||
- name: Install locked dependencies
|
||||
env:
|
||||
UV_PROJECT_ENVIRONMENT: /opt/venv
|
||||
run: uv sync --locked --extra dev
|
||||
|
||||
- name: Run tests
|
||||
run: /opt/venv/bin/python -m pytest tests/ -q
|
||||
# Integration tests (real Postgres) run in the `integration` job below.
|
||||
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
|
||||
|
||||
# Real-Postgres lane (family rule 6). Exercises the async SQLAlchemy connection
|
||||
# path the unit stubs can't reach — the un-awaited execution_options regression
|
||||
# that made every VACUUM report 0/6 lived here. Like `test`, it runs for
|
||||
# visibility and does NOT gate the build.
|
||||
#
|
||||
# Job key stays separator-free ("integration"): act_runner derives the service-
|
||||
# container name from the (truncated) job display name and the discovery step
|
||||
# filters `docker ps` by it. Service hostnames aren't routable on this runner,
|
||||
# so the step resolves the Postgres container's bridge IP. No `name:` on purpose.
|
||||
integration:
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
env:
|
||||
# Config + the module engine read these at import time. DATABASE_URL itself
|
||||
# is built from the discovered service IP in the run step.
|
||||
SECRET_KEY: ci_integration_placeholder
|
||||
services:
|
||||
postgres:
|
||||
# pgvector image so `alembic upgrade head` can run migration 0067
|
||||
# (CREATE EXTENSION vector). PG17 — matches the prod/quickstart image.
|
||||
image: pgvector/pgvector:pg17
|
||||
env:
|
||||
POSTGRES_USER: scribe
|
||||
POSTGRES_PASSWORD: ci_integration
|
||||
POSTGRES_DB: scribe_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U scribe"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
# Same locked install as the unit lane — the two must agree on versions,
|
||||
# or "unit green, integration red" stops being a signal about the code.
|
||||
- name: Install locked dependencies
|
||||
env:
|
||||
UV_PROJECT_ENVIRONMENT: /opt/venv
|
||||
run: uv sync --locked --extra dev
|
||||
- name: Integration suite (resolve service IP, migrate, test)
|
||||
run: |
|
||||
set -eux
|
||||
echo "=== container landscape (diagnostic for the name filter) ==="
|
||||
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
||||
PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg17" -q | head -n1)
|
||||
test -n "$PG"
|
||||
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
||||
test -n "$PG_IP"
|
||||
export DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test"
|
||||
# Wait for Postgres to accept connections (busybox sh — the runner
|
||||
# default — has no bash /dev/tcp, so use Python).
|
||||
/opt/venv/bin/python - "$PG_IP" <<'PY'
|
||||
import socket, sys, time
|
||||
for _ in range(30):
|
||||
try:
|
||||
socket.create_connection((sys.argv[1], 5432), timeout=2).close()
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(1)
|
||||
else:
|
||||
sys.exit("postgres did not become reachable")
|
||||
PY
|
||||
# Real migrations build the schema; the maintenance tests then run
|
||||
# VACUUM (ANALYZE) and read pg_stat_user_tables against it.
|
||||
/opt/venv/bin/alembic upgrade head
|
||||
/opt/venv/bin/python -m pytest tests/ -v -m integration
|
||||
|
||||
build:
|
||||
name: Build & push image
|
||||
# `plugin` is deliberately NOT in needs. The plugin isn't in the image —
|
||||
# installs fetch it from git — so gating the server image on a hook lint
|
||||
# would couple two things that don't ship together, and blocking the build
|
||||
# wouldn't un-publish a bad hook anyway: the push already did that. A failed
|
||||
# `plugin` job still turns the whole run red, which is the signal that
|
||||
# matters.
|
||||
needs: [typecheck, lint, test]
|
||||
# Build on dev, main, and v* tag pushes. dev → :dev, main → :latest,
|
||||
# tag → :latest + :<version>; every build also gets an immutable :<sha>.
|
||||
|
||||
+20
-3
@@ -12,10 +12,27 @@ RUN npm run build
|
||||
FROM python:3.14-slim AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml .
|
||||
COPY src/ src/
|
||||
# Installed from uv.lock, exactly like CI (issue #2194). This used to be
|
||||
# `COPY pyproject.toml .` + `pip install .`, which never even copied the lock:
|
||||
# the shipped image resolved its own dependency set, so CI could be green on one
|
||||
# set of versions while the published image ran another. On 2026-07-28 that
|
||||
# class of drift turned `main` red when mcp 2.0.0 shipped mid-session.
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install .
|
||||
pip install --no-cache-dir uv
|
||||
|
||||
# Dependencies before source, so the expensive layer is cached on every build
|
||||
# that doesn't change the lock.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev --no-install-project
|
||||
|
||||
COPY src/ src/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
# uv sync installs into a project venv rather than the system interpreter, so
|
||||
# alembic and hypercorn in CMD have to be found there.
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
COPY --from=build-frontend /build/dist/ src/scribe/static/
|
||||
COPY alembic.ini .
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Fabled Scribe
|
||||
|
||||
A self-hosted second brain and project management application with integrated LLM capabilities. Write, organise, and act on your notes and tasks with the help of a local AI assistant — all running on your own hardware.
|
||||
A self-hosted work system-of-record for software projects, built to be driven by Claude Code. Notes, tasks, issues, projects, milestones, rules, and stored processes — reachable from Claude via a built-in MCP endpoint and a bundled Claude Code plugin, with a clean web UI for humans. No in-app LLM; Claude is the sole assistant.
|
||||
|
||||
## Features
|
||||
|
||||
Notes and tasks with a Markdown editor, sub-tasks, milestones, and kanban project workspaces. AI chat with streaming responses, RAG over your notes, and tool use (web search, calendar, weather). A daily briefing that digests your tasks, RSS feeds, and weather on a schedule. Knowledge graph, per-user/group sharing, PWA with push notifications, and an MCP server for external AI clients.
|
||||
Notes and tasks with a Markdown editor, sub-tasks, milestones, issues, and kanban project workspaces. Stored processes, an engineering rulebook system, and semantic search with proactive knowledge-injection into Claude's context. A knowledge graph, per-user/group sharing, and a built-in MCP server (`/mcp`) plus a bundled Claude Code plugin so Claude can record and recall your work directly.
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Prerequisites:** Docker and Docker Compose. 8 GB+ RAM recommended for LLM inference.
|
||||
**Prerequisites:** Docker and Docker Compose. No GPU or local model needed — Claude is the sole assistant, reached over MCP.
|
||||
|
||||
Download [`docker-compose.quickstart.yml`](docker-compose.quickstart.yml) from this repo, then:
|
||||
|
||||
@@ -19,9 +19,7 @@ export SECRET_KEY=your-random-secret-here
|
||||
docker compose -f docker-compose.quickstart.yml up -d
|
||||
```
|
||||
|
||||
Open `http://localhost:5000`. The first user to register becomes admin. Go to **Settings → General** to pull an LLM model — `qwen3:8b` or `llama3.1:8b` are good starting points.
|
||||
|
||||
> **GPU:** Ollama runs CPU-only by default. See the comments in `docker-compose.quickstart.yml` to enable NVIDIA GPU passthrough.
|
||||
Open `http://localhost:5000`. The first user to register becomes admin. To connect Claude, create an API key under **Settings → API Keys** and install the Claude Code plugin — see [API Keys & MCP](docs/api-keys-and-mcp.md).
|
||||
|
||||
> **Development:** To build from source, see [Development](docs/development.md).
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""repo -> project bindings
|
||||
|
||||
Revision ID: 0064
|
||||
Revises: 0063
|
||||
Create Date: 2026-06-10
|
||||
|
||||
Maps a git repository (by its normalized remote, `repo_key`) to the Scribe
|
||||
project it represents, so the SessionStart hook can resolve the active project
|
||||
from the working repo instead of a project id pinned in plugin config. FKs
|
||||
CASCADE so deleting a user or project removes the stale binding automatically.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0064"
|
||||
down_revision = "0063"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"repo_bindings",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"project_id",
|
||||
sa.BigInteger(),
|
||||
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("repo_key", sa.Text(), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "repo_key", name="uq_repo_bindings_user_repo"),
|
||||
)
|
||||
op.create_index("ix_repo_bindings_user_id", "repo_bindings", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_repo_bindings_user_id", table_name="repo_bindings")
|
||||
op.drop_table("repo_bindings")
|
||||
@@ -0,0 +1,103 @@
|
||||
"""issues + systems: task_kind=issue, systems, record_systems, arose_from_id
|
||||
|
||||
Revision ID: 0065
|
||||
Revises: 0064
|
||||
Create Date: 2026-06-14
|
||||
|
||||
Adds the corrective-work 'issue' task_kind (same-change CHECK expand per the
|
||||
'new CHECK-enum values need a same-change migration' rule), a per-project
|
||||
self-describing System entity, a many-to-many record<->system join (any
|
||||
note/task/issue), and an issue->originating-task provenance FK.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0065"
|
||||
down_revision = "0064"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. task_kind gains 'issue' (corrective work). DROP+ADD the CHECK in the
|
||||
# same change that introduces the value.
|
||||
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
|
||||
op.create_check_constraint(
|
||||
"notes_task_kind_check", "notes", "task_kind IN ('work','plan','issue')",
|
||||
)
|
||||
|
||||
# 2. Provenance: an issue can point back at the task/feature it arose from.
|
||||
# Distinct from parent_id (sub-task hierarchy).
|
||||
op.add_column("notes", sa.Column("arose_from_id", sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
"fk_notes_arose_from_id", "notes", "notes",
|
||||
["arose_from_id"], ["id"], ondelete="SET NULL",
|
||||
)
|
||||
op.create_index("ix_notes_arose_from_id", "notes", ["arose_from_id"])
|
||||
|
||||
# 3. systems: per-project, reusable, self-describing subsystem/area.
|
||||
op.create_table(
|
||||
"systems",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"user_id", sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"project_id", sa.Integer(),
|
||||
sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("color", sa.Text(), nullable=True),
|
||||
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_systems_project_id", "systems", ["project_id"])
|
||||
op.create_check_constraint(
|
||||
"systems_status_check", "systems", "status IN ('active','archived')",
|
||||
)
|
||||
|
||||
# 4. record_systems: M2M join — any note/task/issue <-> system.
|
||||
op.create_table(
|
||||
"record_systems",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"note_id", sa.Integer(),
|
||||
sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"system_id", sa.Integer(),
|
||||
sa.ForeignKey("systems.id", ondelete="CASCADE"), nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.UniqueConstraint("note_id", "system_id", name="uq_record_systems_note_system"),
|
||||
)
|
||||
op.create_index("ix_record_systems_note_id", "record_systems", ["note_id"])
|
||||
op.create_index("ix_record_systems_system_id", "record_systems", ["system_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("record_systems")
|
||||
op.drop_table("systems")
|
||||
op.drop_index("ix_notes_arose_from_id", table_name="notes")
|
||||
op.drop_constraint("fk_notes_arose_from_id", "notes", type_="foreignkey")
|
||||
op.drop_column("notes", "arose_from_id")
|
||||
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
|
||||
op.create_check_constraint(
|
||||
"notes_task_kind_check", "notes", "task_kind IN ('work','plan')",
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""milestone-as-plan-container: milestones.body holds the plan/design
|
||||
|
||||
Revision ID: 0066
|
||||
Revises: 0065
|
||||
Create Date: 2026-06-14
|
||||
|
||||
T3 of plan #819. The milestone becomes the plan container: its `body` holds
|
||||
the design/intent/purpose (markdown), `description` stays the one-liner, and
|
||||
individual steps live as first-class child tasks (milestone_id) instead of
|
||||
checkboxes crammed into a kind=plan task body. start_planning is reworked to
|
||||
create a milestone instead of a kind=plan task (hard retirement going forward;
|
||||
the 'plan' task_kind enum value stays valid so the historical plan-tasks are
|
||||
left readable in place — no body-shredding backfill).
|
||||
|
||||
Schema change is just one nullable column; no data migration.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0066"
|
||||
down_revision = "0065"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("milestones", sa.Column("body", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("milestones", "body")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""pgvector: note_embeddings.embedding JSONB -> vector(384) + HNSW index
|
||||
|
||||
Revision ID: 0067
|
||||
Revises: 0066
|
||||
Create Date: 2026-06-22
|
||||
|
||||
Moves semantic search off the full-table Python cosine scan onto a native
|
||||
pgvector column so ranking + top-k run as an indexed `ORDER BY embedding <=> :q
|
||||
LIMIT k` in Postgres (see services/embeddings.semantic_search_notes).
|
||||
|
||||
Requires a Postgres image that bundles the `vector` extension — the stack moved
|
||||
from postgres:16-alpine to pgvector/pgvector:pg16 in the same change (compose +
|
||||
CI). `CREATE EXTENSION IF NOT EXISTS vector` below is the in-db half.
|
||||
|
||||
Embeddings are DERIVED data (regenerated from note text by
|
||||
backfill_note_embeddings at startup), so this migration is free to drop any row
|
||||
it can't cleanly convert: only rows whose stored JSONB array is exactly 384-dim
|
||||
are carried over (guarding against stale vectors from an earlier model — the
|
||||
same mixed-dim hazard _cosine_similarity defended against). Dropped rows are
|
||||
re-embedded on next boot.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "0067"
|
||||
down_revision = "0066"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
|
||||
# New native-vector column, populated only from cleanly-convertible rows.
|
||||
# A JSONB array like [0.1, 0.2, ...] renders to text that is exactly
|
||||
# pgvector's input literal, so (embedding::text)::vector is a direct cast.
|
||||
op.execute("ALTER TABLE note_embeddings ADD COLUMN embedding_vec vector(384)")
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE note_embeddings
|
||||
SET embedding_vec = (embedding::text)::vector
|
||||
WHERE jsonb_array_length(embedding) = 384
|
||||
"""
|
||||
)
|
||||
# Stale-dim rows (couldn't convert) are derived data — drop and let the
|
||||
# startup backfill regenerate them at the current dimension.
|
||||
op.execute("DELETE FROM note_embeddings WHERE embedding_vec IS NULL")
|
||||
|
||||
op.execute("ALTER TABLE note_embeddings ALTER COLUMN embedding_vec SET NOT NULL")
|
||||
op.execute("ALTER TABLE note_embeddings DROP COLUMN embedding")
|
||||
op.execute("ALTER TABLE note_embeddings RENAME COLUMN embedding_vec TO embedding")
|
||||
|
||||
# HNSW index for cosine distance — matches Vector.cosine_distance (`<=>`).
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX ix_note_embeddings_embedding_hnsw
|
||||
ON note_embeddings
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Back to JSONB. pgvector renders a vector to a text literal that is a valid
|
||||
# JSON array, so the reverse cast is symmetric. The `vector` extension is
|
||||
# intentionally left installed (other objects may depend on it; dropping an
|
||||
# extension is the riskier, rarely-wanted direction).
|
||||
op.execute("DROP INDEX IF EXISTS ix_note_embeddings_embedding_hnsw")
|
||||
op.execute("ALTER TABLE note_embeddings ADD COLUMN embedding_json jsonb")
|
||||
op.execute("UPDATE note_embeddings SET embedding_json = (embedding::text)::jsonb")
|
||||
op.execute("ALTER TABLE note_embeddings ALTER COLUMN embedding_json SET NOT NULL")
|
||||
op.execute("ALTER TABLE note_embeddings DROP COLUMN embedding")
|
||||
op.execute("ALTER TABLE note_embeddings RENAME COLUMN embedding_json TO embedding")
|
||||
@@ -0,0 +1,60 @@
|
||||
"""retrieval_logs: per-call semantic-retrieval telemetry for KB-injection tuning
|
||||
|
||||
Revision ID: 0068
|
||||
Revises: 0067
|
||||
Create Date: 2026-06-22
|
||||
|
||||
One row per semantic-retrieval call (MCP search tool, REST search route, and —
|
||||
once it lands — the title-first auto-inject path). Captures the effective query
|
||||
params and the score distribution of the results so the similarity threshold
|
||||
and top-k can be tuned from real usage. FK-free on user_id (mirrors app_logs):
|
||||
telemetry should outlive the row it describes.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "0068"
|
||||
down_revision = "0067"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"retrieval_logs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("source", sa.Text(), nullable=False),
|
||||
sa.Column("query", sa.Text(), nullable=True),
|
||||
sa.Column("threshold", sa.Float(), nullable=True),
|
||||
sa.Column("limit_n", sa.Integer(), nullable=True),
|
||||
sa.Column("project_id", sa.Integer(), nullable=True),
|
||||
sa.Column("is_task", sa.Boolean(), nullable=True),
|
||||
sa.Column("result_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("top_score", sa.Float(), nullable=True),
|
||||
sa.Column("min_score", sa.Float(), nullable=True),
|
||||
sa.Column("result_ids", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column("duration_ms", sa.Float(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_retrieval_logs_created_at", "retrieval_logs", ["created_at"])
|
||||
op.create_index("ix_retrieval_logs_user_id", "retrieval_logs", ["user_id"])
|
||||
op.create_index("ix_retrieval_logs_source", "retrieval_logs", ["source"])
|
||||
op.create_index(
|
||||
"ix_retrieval_logs_source_created_at",
|
||||
"retrieval_logs",
|
||||
["source", sa.text("created_at DESC")],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_retrieval_logs_source_created_at", table_name="retrieval_logs")
|
||||
op.drop_index("ix_retrieval_logs_source", table_name="retrieval_logs")
|
||||
op.drop_index("ix_retrieval_logs_user_id", table_name="retrieval_logs")
|
||||
op.drop_index("ix_retrieval_logs_created_at", table_name="retrieval_logs")
|
||||
op.drop_table("retrieval_logs")
|
||||
@@ -0,0 +1,82 @@
|
||||
"""drop events table + notes.metadata column (retire calendar + entity surfaces)
|
||||
|
||||
Revision ID: 0069
|
||||
Revises: 0068
|
||||
Create Date: 2026-07-19
|
||||
|
||||
The personal-assistant surfaces (calendar/events + CalDAV, and the typed
|
||||
person/place/list entities that stored structured fields in notes.metadata)
|
||||
were removed when Scribe narrowed to a Claude-Code work system-of-record.
|
||||
This migration drops their storage:
|
||||
|
||||
- the `events` table (all calendar/CalDAV data)
|
||||
- the `notes.metadata` (entity_meta) JSONB column — it only ever held
|
||||
person/place/list structured fields. The `note_type` column STAYS: it
|
||||
also distinguishes 'process' notes.
|
||||
- orphan CalDAV settings rows (nothing reads them after the removal)
|
||||
|
||||
Downgrade recreates the table + column structure at its pre-removal shape.
|
||||
The dropped data itself is not recoverable.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "0069"
|
||||
down_revision = "0068"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Entity metadata column (person/place/list structured fields). The
|
||||
# note_type column is intentionally kept — it also marks 'process' notes.
|
||||
op.drop_column("notes", "metadata")
|
||||
|
||||
# Calendar / CalDAV storage. Dropping the table drops its indexes + the
|
||||
# duration CHECK constraint with it.
|
||||
op.drop_table("events")
|
||||
|
||||
# Orphan CalDAV integration settings — no code reads them post-removal.
|
||||
op.execute("DELETE FROM settings WHERE key LIKE 'caldav%'")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate the events table at its pre-removal schema (empty — the data is
|
||||
# gone). Mirrors the model as of 0037 (reminders) + 0043 (duration_minutes)
|
||||
# + 0057 (soft-delete columns/index).
|
||||
op.create_table(
|
||||
"events",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("project_id", sa.Integer(),
|
||||
sa.ForeignKey("projects.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("uid", sa.Text(), nullable=False),
|
||||
sa.Column("title", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("start_dt", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("duration_minutes", sa.Integer(), nullable=True),
|
||||
sa.Column("all_day", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("description", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("location", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("caldav_uid", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("color", sa.Text(), nullable=False, server_default=""),
|
||||
sa.Column("recurrence", sa.Text(), nullable=True),
|
||||
sa.Column("reminder_minutes", sa.Integer(), nullable=True),
|
||||
sa.Column("reminder_sent_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True),
|
||||
nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True),
|
||||
nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
|
||||
sa.CheckConstraint(
|
||||
"duration_minutes IS NULL OR duration_minutes >= 0",
|
||||
name="events_duration_minutes_non_negative",
|
||||
),
|
||||
)
|
||||
op.create_index("ix_events_deleted_at", "events", ["deleted_at"])
|
||||
|
||||
# Re-add the entity metadata column.
|
||||
op.add_column("notes", sa.Column("metadata", JSONB(), nullable=True))
|
||||
@@ -0,0 +1,60 @@
|
||||
"""add notes.data JSONB — queryable structured fields for typed records
|
||||
|
||||
Revision ID: 0070
|
||||
Revises: 0069
|
||||
Create Date: 2026-07-26
|
||||
|
||||
Snippets (note_type='snippet') carry structured fields — name, language,
|
||||
signature, and a list of canonical locations (repo · path · symbol). Those were
|
||||
stored as a markdown body-convention, which reads well and feeds the embedding
|
||||
but cannot be QUERIED: answering "which snippets live in this file?" meant
|
||||
scanning every snippet and regexing its body.
|
||||
|
||||
This adds a general `data` JSONB column plus a GIN index, so those fields become
|
||||
indexable. The body stays exactly as it was — it is still the human-readable
|
||||
form and still what gets embedded. `data` is a queryable mirror of the same
|
||||
facts, not a replacement, and the code itself is deliberately NOT copied into it
|
||||
(the body already holds it; duplicating a blob to index fields around it would
|
||||
be waste).
|
||||
|
||||
Relationship to 0069: that migration DROPPED `notes.metadata`, a JSONB column
|
||||
which only ever held person/place/list entity fields, when those surfaces were
|
||||
removed. This is not a revival of it — different name, different purpose, and
|
||||
nothing reads the old shape. The column is named `data` rather than `metadata`
|
||||
because `metadata` collides with SQLAlchemy's declarative `Base.metadata`, which
|
||||
is why the old model had to map an awkward `entity_metadata` attribute onto it.
|
||||
|
||||
Nullable with no backfill, deliberately: rows written before this migration keep
|
||||
working because the service falls back to parsing the body when `data` is
|
||||
absent. That means no migration deadline and no risk of a backfill mangling a
|
||||
hand-edited body.
|
||||
|
||||
Downgrade drops the index and the column. Any structured fields it held remain
|
||||
recoverable from the body convention, which is the same source they mirror.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "0070"
|
||||
down_revision = "0069"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("notes", sa.Column("data", JSONB, nullable=True))
|
||||
# GIN supports containment (`data @> '{"locations":[{"repo":"x"}]}'`), which
|
||||
# covers exact repo/path/symbol and language lookups. Path PREFIX matching
|
||||
# ("everything under frontend/src/") is not an index-served operation here
|
||||
# and still filters after the fact — acceptable while snippet counts are
|
||||
# small, and a generated column is the escape hatch if that changes.
|
||||
op.create_index(
|
||||
"ix_notes_data_gin", "notes", ["data"], postgresql_using="gin",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_notes_data_gin", table_name="notes")
|
||||
op.drop_column("notes", "data")
|
||||
@@ -0,0 +1,72 @@
|
||||
"""add note_usage_events — did anyone actually open what we surfaced?
|
||||
|
||||
Revision ID: 0071
|
||||
Revises: 0070
|
||||
Create Date: 2026-07-28
|
||||
|
||||
`retrieval_logs` records what the ranker returned and with what scores, which is
|
||||
the right substrate for tuning a similarity threshold. It cannot answer the
|
||||
different question the snippet corpus needs: was a surfaced snippet ever pulled
|
||||
in full? A snippet nobody opens still competes for the injection budget on every
|
||||
turn, so the surfaced:pulled ratio is what makes dead weight visible.
|
||||
|
||||
Two reasons this is its own table rather than columns on `notes` or rows in
|
||||
`retrieval_logs`:
|
||||
|
||||
- Counters on `notes` would answer "how many" but not "when, from where, and
|
||||
by which arm" — and the place arm vs semantic arm comparison is precisely
|
||||
what was missing (the write-path place arm surfaced snippets while leaving
|
||||
no trace anywhere).
|
||||
- Folding un-scored surfacing into `retrieval_logs` would corrupt the score
|
||||
distribution that table exists to capture. Location hits have no score.
|
||||
|
||||
Grain is one row per note per event, which is what the per-snippet readout needs
|
||||
and what `retrieval_logs.result_ids` (a JSONB array, one row per *call*) cannot
|
||||
be indexed at.
|
||||
|
||||
FK-free on note_id and user_id, matching retrieval_logs and app_logs: telemetry
|
||||
should outlive what it describes. Deleting a note must not erase the evidence
|
||||
that it was surfaced forty times and opened none.
|
||||
|
||||
Downgrade drops the table outright. The data is purely observational — nothing
|
||||
reads it for correctness, so losing it costs history and no behavior.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0071"
|
||||
down_revision = "0070"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"note_usage_events",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("user_id", sa.Integer(), nullable=True),
|
||||
sa.Column("note_id", sa.Integer(), nullable=False),
|
||||
sa.Column("event", sa.Text(), nullable=False),
|
||||
sa.Column("source", sa.Text(), nullable=False),
|
||||
)
|
||||
# Every readout is "these note ids, split by event", so the composite is the
|
||||
# one that actually gets used; the others serve pruning and per-user views.
|
||||
op.create_index(
|
||||
"ix_note_usage_note_event", "note_usage_events", ["note_id", "event"]
|
||||
)
|
||||
op.create_index("ix_note_usage_created_at", "note_usage_events", ["created_at"])
|
||||
op.create_index("ix_note_usage_user_id", "note_usage_events", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_note_usage_user_id", table_name="note_usage_events")
|
||||
op.drop_index("ix_note_usage_created_at", table_name="note_usage_events")
|
||||
op.drop_index("ix_note_usage_note_event", table_name="note_usage_events")
|
||||
op.drop_table("note_usage_events")
|
||||
@@ -0,0 +1,138 @@
|
||||
"""design systems + tokens, and the project pointer
|
||||
|
||||
Revision ID: 0072
|
||||
Revises: 0071
|
||||
Create Date: 2026-07-30
|
||||
|
||||
Makes the design system a first-class record instead of prose in a rulebook: a
|
||||
named set of tokens with an optional parent, so a family system holds the house
|
||||
style and an app system holds only what it changes.
|
||||
|
||||
`design_systems.parent_id` is the whole model. It replaces both an `always_on`
|
||||
flag (a family system is one with no parent) and a subscription join table (a
|
||||
project points at ONE system, and the chain supplies the rest), which is less
|
||||
schema than the rulebook shape it mirrors.
|
||||
|
||||
Two deliberate choices worth stating here rather than leaving to be re-derived:
|
||||
|
||||
- **`design_tokens.value_by_mode` is JSONB keyed by mode**, not `value_light` +
|
||||
`value_dark` columns. In a child system an unset mode means "inherit"; in a
|
||||
root it would mean "not mode-dependent", and as columns both are NULL and
|
||||
indistinguishable. As a map, resolution is a dict merge at every level with
|
||||
no special case for roots — and a third mode (high-contrast, print) is data
|
||||
rather than a schema change. The cost is that a typo'd mode key is not
|
||||
rejected by the database. Nothing filters tokens by value in SQL, so the
|
||||
queryability the columns would have bought is for a query no caller makes.
|
||||
(Named `value_by_mode` rather than `values`, which is reserved in SQL.)
|
||||
- **`group_name` is free text, not a CHECK enum.** Groupings are each design
|
||||
system's own vocabulary; a whitelist would bake one install's kit into the
|
||||
schema. No CHECK is introduced anywhere in this migration.
|
||||
|
||||
`parent_id` and `projects.design_system_id` are both ON DELETE SET NULL. Deleting
|
||||
a family system must orphan its children into roots that still hold their own
|
||||
overrides, not cascade away every app system that inherited from it; deleting a
|
||||
system a project points at must unstyle that project, not delete it.
|
||||
|
||||
Downgrade drops both tables and the column. Any design system defined this way
|
||||
is lost — this is the migration that introduces the concept, so there is no
|
||||
earlier representation to fall back to.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "0072"
|
||||
down_revision = "0071"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"design_systems",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column(
|
||||
"owner_user_id", sa.BigInteger(),
|
||||
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
|
||||
),
|
||||
sa.Column("title", sa.Text(), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"parent_id", sa.BigInteger(),
|
||||
sa.ForeignKey("design_systems.id", ondelete="SET NULL"), nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_design_systems_owner_user_id", "design_systems", ["owner_user_id"]
|
||||
)
|
||||
op.create_index("ix_design_systems_parent_id", "design_systems", ["parent_id"])
|
||||
|
||||
op.create_table(
|
||||
"design_tokens",
|
||||
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||
sa.Column(
|
||||
"design_system_id", sa.BigInteger(),
|
||||
sa.ForeignKey("design_systems.id", ondelete="CASCADE"), nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
# NOT NULL with a '{}' default: a nullable JSONB column has two empty
|
||||
# states (SQL NULL and JSON null) and every reader has to test for both.
|
||||
sa.Column(
|
||||
"value_by_mode", JSONB,
|
||||
nullable=False, server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
sa.Column("group_name", sa.Text(), nullable=True),
|
||||
sa.Column("purpose", sa.Text(), nullable=True),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column(
|
||||
"created_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at", sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.text("now()"),
|
||||
),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_design_tokens_design_system_id", "design_tokens", ["design_system_id"]
|
||||
)
|
||||
# Partial unique: a name is unique among LIVE tokens in a system. Two live
|
||||
# rows with the same name are a duplicate definition and the cascade would
|
||||
# pick between them arbitrarily; a trashed row must not block reusing its
|
||||
# name.
|
||||
op.create_index(
|
||||
"uq_token_per_design_system", "design_tokens", ["design_system_id", "name"],
|
||||
unique=True, postgresql_where=sa.text("deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"projects", sa.Column("design_system_id", sa.BigInteger(), nullable=True)
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_projects_design_system_id", "projects", "design_systems",
|
||||
["design_system_id"], ["id"], ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_projects_design_system_id", "projects", type_="foreignkey")
|
||||
op.drop_column("projects", "design_system_id")
|
||||
op.drop_index("uq_token_per_design_system", table_name="design_tokens")
|
||||
op.drop_index("ix_design_tokens_design_system_id", table_name="design_tokens")
|
||||
op.drop_table("design_tokens")
|
||||
op.drop_index("ix_design_systems_parent_id", table_name="design_systems")
|
||||
op.drop_index("ix_design_systems_owner_user_id", table_name="design_systems")
|
||||
op.drop_table("design_systems")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""design_tokens.supersedes — the literals a token should be used instead of
|
||||
|
||||
Revision ID: 0073
|
||||
Revises: 0072
|
||||
Create Date: 2026-07-30
|
||||
|
||||
Records what a prohibition was actually trying to say.
|
||||
|
||||
A design rulebook writes "pure white #FFFFFF is NEVER used as text color". That
|
||||
sentence has no row in a table of tokens, because a design system stores what
|
||||
things ARE — which looked like a gap in the model and was really a sentence
|
||||
written backwards. The positive fact is "text is Parchment", and the useful
|
||||
record is the mapping from the literal someone would otherwise write to the
|
||||
token they should write instead.
|
||||
|
||||
So `supersedes` is a JSONB array of literal values, e.g. `["#fff", "#ffffff"]`
|
||||
on a text-on-action token. A finding built from it can say what to write, not
|
||||
merely what not to.
|
||||
|
||||
It must be DECLARED rather than derived. `#fff` and Parchment `#E8E4D8` are
|
||||
different colours, so no value-matching rule could ever have connected them —
|
||||
which is exactly why the prohibition felt unrepresentable until it was turned
|
||||
around.
|
||||
|
||||
Consumed by the source lint that reads component CSS, not by the drift panel:
|
||||
these literals are in the components, which the panel cannot see.
|
||||
|
||||
NOT NULL with a `'[]'` default, matching `value_by_mode` — a nullable JSONB
|
||||
column has two empty states and every reader has to test for both.
|
||||
|
||||
Downgrade drops the column; the declarations are lost, which costs the lint its
|
||||
input and nothing else.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
revision = "0073"
|
||||
down_revision = "0072"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"design_tokens",
|
||||
sa.Column(
|
||||
"supersedes", JSONB, nullable=False, server_default=sa.text("'[]'::jsonb")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("design_tokens", "supersedes")
|
||||
@@ -0,0 +1,44 @@
|
||||
"""central prose on a design system, and rationale on a token
|
||||
|
||||
Revision ID: 0074
|
||||
Revises: 0073
|
||||
Create Date: 2026-07-31
|
||||
|
||||
The operator's shape for #254: prose doesn't live as one-offs, it lives centrally
|
||||
on the system. Two fields rather than one per category:
|
||||
|
||||
design_systems.guidance the narrative a token table cannot hold — aesthetic,
|
||||
voice and tone, what is deliberately out of scope.
|
||||
Markdown, free-form.
|
||||
design_tokens.rationale WHY this token is this value, which is a different
|
||||
question from `purpose` (what it is FOR). "Success
|
||||
equals Moss, aligned by design" is a rationale;
|
||||
"page bg, deepest surface" is a purpose.
|
||||
|
||||
Free-form rather than a column per category on purpose. A schema with `voice`,
|
||||
`aesthetic` and `scope` columns would bake one rulebook's table of contents into
|
||||
every install (rule #115), and the next install's design system would have three
|
||||
empty columns and nowhere to put what it actually cares about.
|
||||
|
||||
Both nullable: a design system with no prose at all is complete, not a draft.
|
||||
|
||||
Downgrade drops both columns and the prose with them.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0074"
|
||||
down_revision = "0073"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("design_systems", sa.Column("guidance", sa.Text(), nullable=True))
|
||||
op.add_column("design_tokens", sa.Column("rationale", sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("design_tokens", "rationale")
|
||||
op.drop_column("design_systems", "guidance")
|
||||
+15
-11
@@ -9,8 +9,9 @@
|
||||
git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
```
|
||||
|
||||
Used by all four jobs in `.forgejo/workflows/ci.yml`: typecheck (Vue/TS),
|
||||
lint (ruff), test (pytest), build (docker buildx).
|
||||
Used by all six jobs in `.forgejo/workflows/ci.yml`: typecheck (Vue/TS),
|
||||
plugin (hook checks), lint (ruff), test (pytest), integration (pytest +
|
||||
real Postgres), build (docker buildx).
|
||||
|
||||
## Image deps used
|
||||
|
||||
@@ -18,22 +19,25 @@ lint (ruff), test (pytest), build (docker buildx).
|
||||
- node 24 (used for `npm ci` + `vue-tsc` in the typecheck job, and as the
|
||||
frontend builder stage inside the production `Dockerfile`)
|
||||
- ruff (lint job runs `ruff check src/` with zero install overhead)
|
||||
- uv (test + integration jobs run `uv sync --locked`; installed in the
|
||||
image since the ci-python Dockerfile started pip-installing it)
|
||||
- docker CLI + buildx (build job pushes the production image to the
|
||||
Forgejo registry)
|
||||
Fabled-Git registry)
|
||||
|
||||
## Per-job tool installs
|
||||
|
||||
Anything CI installs at job time that isn't in the image. Promotion
|
||||
candidates if more than one project needs them.
|
||||
|
||||
- `uv` — installed inline in the test job (`curl -LsSf
|
||||
https://astral.sh/uv/install.sh | sh`). **Temporary**: belongs in the
|
||||
ci-python image so every consumer doesn't re-install on cold start.
|
||||
Tracked at [CI-Runner](https://git.fabledsword.com/bvandeusen/CI-runner).
|
||||
- `http-ece` is `--no-build-isolation`-installed before the editable
|
||||
package install because http-ece doesn't declare `setuptools` as a
|
||||
build dep and uv creates bare venvs without it. Not promotion-worthy
|
||||
(one project, one wheel).
|
||||
- `jq` + `shellcheck` — apt-installed in the **plugin** job, which lints
|
||||
the four Claude Code hook scripts and runs their fail-open smoke test.
|
||||
Per `docs/process.md`'s decision checkpoint, single-consumer deps stay
|
||||
per-job until a second consumer wants them; Scribe is the only one so
|
||||
far. Both are small (jq ~1 MB, shellcheck ~20 MB) and would be
|
||||
promotion candidates the moment another project lints shell.
|
||||
**jq is load-bearing for the smoke test specifically**: every hook
|
||||
starts with `command -v jq || exit 0`, so without it the test passes
|
||||
while exercising nothing.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -21,7 +21,11 @@ services:
|
||||
max_attempts: 5
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
# pgvector image (Debian/glibc, PG17) — bundles the `vector` extension that
|
||||
# migration 0067 enables. Moved off postgres:16-alpine via logical
|
||||
# dump/restore (which doubles as the PG16->PG17 major upgrade); see the
|
||||
# TRANSITION runbook in the PR.
|
||||
image: pgvector/pgvector:pg17
|
||||
stop_grace_period: 120s
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
@@ -35,7 +35,8 @@ services:
|
||||
start_period: 30s
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
# pgvector image (PG17) — bundles the `vector` extension (migration 0067).
|
||||
image: pgvector/pgvector:pg17
|
||||
stop_grace_period: 120s
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
@@ -86,8 +86,7 @@ table here. The tools are grouped by family:
|
||||
| Tasks | `create_task`, `update_task`, `add_task_log`, `start_planning` | Actionable work + plans |
|
||||
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
|
||||
| Search / Recall | `search`, `get_recent`, `list_tags` | Semantic + structured recall |
|
||||
| Typed entities | `create_person`, `create_place`, `create_list`, … | Structured records |
|
||||
| Events | `create_event`, `list_events`, `update_event`, … | Calendar |
|
||||
| Systems | `create_system`, `list_systems`, `list_system_records` | Reusable per-project subsystems/areas |
|
||||
| Rulebooks | `list_always_on_rules`, `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules |
|
||||
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
|
||||
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
|
||||
|
||||
+135
-171
@@ -1,242 +1,206 @@
|
||||
# API Reference
|
||||
|
||||
All endpoints require login (session cookie or `Authorization: Bearer <api-key>`) unless marked **(public)**.
|
||||
All endpoints are JSON over HTTP under `/api`, and require login unless marked
|
||||
**(public)**. Browser sessions authenticate with a cookie; programmatic clients use an
|
||||
`fmcp_` API key as `Authorization: Bearer <key>` (see
|
||||
[API Keys & MCP](api-keys-and-mcp.md)). Claude reaches the same data through the MCP
|
||||
endpoint at `/mcp`, not these REST routes.
|
||||
|
||||
## Health
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/health` | Health check **(public)** |
|
||||
| GET | `/api/version` | App version **(public)** |
|
||||
|
||||
## Auth
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/auth/status` | `{has_users, registration_open, oauth_enabled, local_auth_enabled}` **(public)** |
|
||||
| POST | `/api/auth/register` | Register new user (first user becomes admin; 403 if registration closed or local auth disabled) |
|
||||
| POST | `/api/auth/login` | Login with username/password (403 if local auth disabled) |
|
||||
| POST | `/api/auth/register` | Register (first user becomes admin) |
|
||||
| POST | `/api/auth/login` | Login with username/password |
|
||||
| POST | `/api/auth/logout` | Clear session |
|
||||
| GET | `/api/auth/me` | Current user info (includes `has_password: bool`) |
|
||||
| PUT | `/api/auth/password` | Change password `{current_password, new_password}` |
|
||||
| PUT | `/api/auth/email` | Change email `{email, password?}` (password required only for local-auth users) |
|
||||
| POST | `/api/auth/invalidate-sessions` | Bump `session_version` — evicts all other sessions, keeps current alive |
|
||||
| POST | `/api/auth/forgot-password` | Send password reset email `{email}` |
|
||||
| POST | `/api/auth/reset-password` | Reset password with token `{token, new_password}` |
|
||||
| GET | `/api/auth/oauth/login` | Initiate OIDC PKCE flow → redirect to provider |
|
||||
| GET | `/api/auth/oauth/callback` | OIDC callback — exchange code, find/create user, redirect to `/` |
|
||||
| GET | `/api/auth/invitation/:token` | Validate invitation token **(public)** |
|
||||
| POST | `/api/auth/register-with-invite` | Register with token `{token, username, password}` **(public)** |
|
||||
| GET | `/api/auth/me` | Current user info |
|
||||
| PUT | `/api/auth/password` | Change password |
|
||||
| PUT | `/api/auth/email` | Change email |
|
||||
| POST | `/api/auth/invalidate-sessions` | Evict all other sessions |
|
||||
| POST | `/api/auth/forgot-password` | Send password-reset email |
|
||||
| POST | `/api/auth/reset-password` | Reset password with token |
|
||||
| GET | `/api/auth/oauth/login` | Begin OIDC (PKCE) flow |
|
||||
| GET | `/api/auth/oauth/callback` | OIDC callback |
|
||||
| GET | `/api/auth/invitation/:token` | Validate an invite **(public)** |
|
||||
| POST | `/api/auth/register-with-invite` | Register with a token **(public)** |
|
||||
|
||||
## Notes
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/notes` | List notes. Params: `q`, `tag`, `sort`, `order`, `limit`, `offset`, `project_id`, `milestone_id`, `parent_id`, `type` (`note`/`task`/`all`) |
|
||||
| POST | `/api/notes` | Create note `{title, body, tags?, status?, priority?, due_date?, project_id?, milestone_id?, parent_id?}` |
|
||||
| GET | `/api/notes/tags` | All tags (param: `q` for filter) |
|
||||
| POST | `/api/notes/suggest-tags` | LLM tag suggestions `{title, body, current_tags?}` → `{suggested_tags}` |
|
||||
| POST | `/api/notes/link-suggestions` | Detect note titles as plain text in body `{body, project_id, exclude_note_id}` → `[{note_id, title, count}]` |
|
||||
| GET | `/api/notes/by-title` | Resolve note by exact title (param: `title`) |
|
||||
| POST | `/api/notes/resolve-title` | Get-or-create note by title `{title}` (wikilink click) |
|
||||
| GET | `/api/notes/:id` | Get single note |
|
||||
| PUT | `/api/notes/:id` | Full update |
|
||||
| PATCH | `/api/notes/:id` | Partial update (same fields as PUT) |
|
||||
| DELETE | `/api/notes/:id` | Delete note |
|
||||
| POST | `/api/notes/:id/convert-to-task` | Set `status='todo'`, `priority='none'` |
|
||||
| POST | `/api/notes/:id/convert-to-note` | Clear `status`, `priority`, `due_date` |
|
||||
| POST | `/api/notes/:id/append-tag` | Add tag `{tag}` → updated note |
|
||||
| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`, `project_id`, `milestone_id`, `parent_id`) |
|
||||
| POST | `/api/notes` | Create note |
|
||||
| GET | `/api/notes/tags` | All tags (param: `q`) |
|
||||
| POST | `/api/notes/:id/append-tag` | Add a tag `{tag}` |
|
||||
| POST | `/api/notes/link-suggestions` | Detect note titles as plain text → wikilink candidates |
|
||||
| GET | `/api/notes/by-title` | Resolve note by exact title |
|
||||
| POST | `/api/notes/resolve-title` | Get-or-create by title (wikilink click) |
|
||||
| GET / PUT / PATCH / DELETE | `/api/notes/:id` | Read / replace / patch / delete |
|
||||
| POST | `/api/notes/:id/convert-to-task` | Note → task |
|
||||
| POST | `/api/notes/:id/convert-to-note` | Task → note |
|
||||
| GET | `/api/notes/:id/backlinks` | Notes/tasks with `[[Title]]` references to this note |
|
||||
| GET | `/api/notes/:id/versions` | List note version history |
|
||||
| GET | `/api/notes/:id/versions/:vid` | Get a specific version |
|
||||
| GET | `/api/notes/:id/draft` | Get current AI draft |
|
||||
| PUT | `/api/notes/:id/draft` | Save AI draft |
|
||||
| DELETE | `/api/notes/:id/draft` | Delete AI draft |
|
||||
| POST | `/api/notes/assist` | Launch AI assist generation → 202 `{body, target_section?, instruction, whole_doc?}` |
|
||||
| GET | `/api/notes/assist/stream` | SSE stream for assist (Last-Event-ID reconnect; events: `chunk`, `done`, `error`) |
|
||||
| GET | `/api/notes/:id/versions` | Version history |
|
||||
| GET | `/api/notes/:id/versions/:vid` | A specific version |
|
||||
| POST | `/api/notes/:id/versions/:vid/pin` | Pin a version |
|
||||
| GET / PUT / DELETE | `/api/notes/:id/draft` | Unsaved-edit draft (restore across page loads) |
|
||||
| GET | `/api/notes/graph` | Knowledge-graph nodes/edges |
|
||||
|
||||
## Tasks
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/tasks` | List tasks. Params: `q`, `tag`, `status`, `priority`, `due_before`, `due_after`, `sort`, `order`, `limit`, `offset` |
|
||||
| POST | `/api/tasks` | Create task (accepts `project` name string → resolved to `project_id`) |
|
||||
| GET | `/api/tasks/:id` | Get task (includes `parent_title`) |
|
||||
| PUT | `/api/tasks/:id` | Full update |
|
||||
| GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `overdue`, `sort`, `order`, `limit`, `offset`) |
|
||||
| POST | `/api/tasks` | Create task (accepts a `project` name string → resolved to `project_id`) |
|
||||
| POST | `/api/tasks/planning` | Start a plan (milestone + steps) |
|
||||
| GET / PUT / PATCH / DELETE | `/api/tasks/:id` | Read / update / delete |
|
||||
| PATCH | `/api/tasks/:id/status` | Quick status update `{status}` |
|
||||
| DELETE | `/api/tasks/:id` | Delete task |
|
||||
| GET | `/api/tasks/:id/logs` | List work logs |
|
||||
| POST | `/api/tasks/:id/logs` | Create log `{content, duration_minutes?}` |
|
||||
| PATCH | `/api/tasks/:id/logs/:log_id` | Update log |
|
||||
| DELETE | `/api/tasks/:id/logs/:log_id` | Delete log |
|
||||
| GET | `/api/tasks/:id/recurrence-preview` | Preview next recurrence occurrences |
|
||||
|
||||
## Projects & Milestones
|
||||
**Task work logs**
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/projects` | List projects (owned + shared) |
|
||||
| POST | `/api/projects` | Create project |
|
||||
| GET | `/api/projects/:id` | Get project with `milestone_summary` |
|
||||
| PATCH | `/api/projects/:id` | Update project |
|
||||
| DELETE | `/api/projects/:id` | Delete project |
|
||||
| GET / POST | `/api/tasks/:id/logs` | List / append a work log `{content, duration_minutes?}` |
|
||||
| PATCH / DELETE | `/api/tasks/:id/logs/:log_id` | Update / delete a log |
|
||||
|
||||
## Projects, Milestones, Systems, Issues
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET / POST | `/api/projects` | List (owned + shared) / create |
|
||||
| GET / PATCH / DELETE | `/api/projects/:id` | Read (with `milestone_summary`) / update / delete |
|
||||
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
|
||||
| GET | `/api/projects/:id/milestones` | List milestones |
|
||||
| POST | `/api/projects/:id/milestones` | Create milestone |
|
||||
| PATCH | `/api/projects/:id/milestones/:mid` | Update milestone |
|
||||
| DELETE | `/api/projects/:id/milestones/:mid` | Delete milestone |
|
||||
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
|
||||
| GET / PATCH / DELETE | `/api/projects/:id/milestones/:mid` | Read / update / delete |
|
||||
| GET | `/api/projects/:id/milestones/:mid/tasks` | Tasks in a milestone |
|
||||
| GET / POST | `/api/projects/:id/systems` | List / create systems |
|
||||
| GET / PATCH / DELETE | `/api/projects/:id/systems/:sid` | Read / update / delete |
|
||||
| GET | `/api/projects/:id/systems/:sid/records` | Records linked to a system |
|
||||
| GET | `/api/projects/:id/issues` | Project issues |
|
||||
|
||||
## Knowledge browse
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/knowledge` | Unified note/task/plan/process feed (params: `type`, `tags`, `sort`, `q`, `limit`, `offset`) |
|
||||
| GET | `/api/knowledge/ids` | ID-only page (two-tier pagination) |
|
||||
| GET | `/api/knowledge/batch` | Fetch items by id |
|
||||
| GET | `/api/knowledge/tags` | Tags in scope |
|
||||
| GET | `/api/knowledge/counts` | Per-type counts |
|
||||
|
||||
## Search
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/search` | Semantic + keyword search over notes/tasks (params: `q`, `type`, `limit`) |
|
||||
|
||||
## Rulebooks and rules
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET / POST | `/api/rulebooks` | List / create rulebooks |
|
||||
| GET / PATCH / DELETE | `/api/rulebooks/:id` | Read / update / delete |
|
||||
| GET / POST | `/api/rulebooks/:id/topics` | List / create topics |
|
||||
| PATCH / DELETE | `/api/rulebook-topics/:tid` | Edit / delete a topic |
|
||||
| GET | `/api/rules` | List rules |
|
||||
| POST | `/api/rulebook-topics/:tid/rules` | Add a rule to a topic |
|
||||
| GET / PATCH / DELETE | `/api/rules/:id` | Read / update / delete a rule |
|
||||
| POST | `/api/projects/:id/rulebook-subscriptions` | Subscribe a project to a rulebook |
|
||||
| GET | `/api/projects/:id/rules` | Applicable rules for a project |
|
||||
| POST | `/api/projects/:id/rules` | Create a project-scoped rule |
|
||||
| POST / DELETE | `/api/projects/:id/suppressions/rules/:rid` | Suppress / unsuppress a rule |
|
||||
| POST / DELETE | `/api/projects/:id/suppressions/topics/:tid` | Suppress / unsuppress a topic |
|
||||
|
||||
## Sharing
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/projects/:id/shares` | List project shares |
|
||||
| POST | `/api/projects/:id/shares` | Create project share `{user_id?, group_id?, permission}` |
|
||||
| PATCH | `/api/projects/:id/shares/:sid` | Update permission |
|
||||
| DELETE | `/api/projects/:id/shares/:sid` | Remove share |
|
||||
| GET | `/api/notes/:id/shares` | List note shares |
|
||||
| POST | `/api/notes/:id/shares` | Create note share |
|
||||
| PATCH | `/api/notes/:id/shares/:sid` | Update permission |
|
||||
| DELETE | `/api/notes/:id/shares/:sid` | Remove share |
|
||||
| GET / POST | `/api/projects/:id/shares` | List / add project shares `{user_id?, group_id?, permission}` |
|
||||
| PATCH / DELETE | `/api/projects/:id/shares/:sid` | Update permission / remove |
|
||||
| GET / POST | `/api/notes/:id/shares` | List / add note shares |
|
||||
| PATCH / DELETE | `/api/notes/:id/shares/:sid` | Update permission / remove |
|
||||
| GET | `/api/shared-with-me` | All resources shared with the current user |
|
||||
|
||||
## Groups
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/groups` | List all groups (admin only) |
|
||||
| POST | `/api/groups` | Create group `{name, description?}` |
|
||||
| PATCH | `/api/groups/:id` | Update group |
|
||||
| DELETE | `/api/groups/:id` | Delete group |
|
||||
| GET | `/api/groups/:id/members` | List members |
|
||||
| POST | `/api/groups/:id/members` | Add member `{user_id, role}` |
|
||||
| PATCH | `/api/groups/:id/members/:uid` | Update member role |
|
||||
| DELETE | `/api/groups/:id/members/:uid` | Remove member |
|
||||
|
||||
## Chat
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/chat/conversations` | List conversations (params: `limit`, `offset`) |
|
||||
| POST | `/api/chat/conversations` | Create conversation `{title?, model?}` |
|
||||
| POST | `/api/chat/conversations/bulk-delete` | Delete multiple conversations `{ids: number[]}` |
|
||||
| GET | `/api/chat/conversations/:id` | Get conversation with all messages |
|
||||
| PATCH | `/api/chat/conversations/:id` | Update title or model |
|
||||
| DELETE | `/api/chat/conversations/:id` | Delete conversation (cascades to messages) |
|
||||
| POST | `/api/chat/conversations/:id/messages` | Start generation → 202. Body: `{content, context_note_id?, include_note_ids?, rag_project_id?, workspace_project_id?, think?}` |
|
||||
| GET | `/api/chat/conversations/:id/generation/stream` | SSE stream (Last-Event-ID reconnect; events: `context`, `chunk`, `tool_call`, `status`, `done`, `error`) |
|
||||
| POST | `/api/chat/conversations/:id/generation/cancel` | Cancel active generation |
|
||||
| POST | `/api/chat/messages/:id/save-as-note` | Save assistant message as note |
|
||||
| POST | `/api/chat/conversations/:id/summarize` | Summarize conversation → note |
|
||||
| GET | `/api/chat/status` | Ollama availability + model state `{ollama, model, default_model}` |
|
||||
| GET | `/api/chat/models` | List installed Ollama models (includes `loaded: bool`, `modified_at`) |
|
||||
| POST | `/api/chat/models/pull` | Pull model (SSE NDJSON progress) `{model}` |
|
||||
| POST | `/api/chat/models/delete` | Delete model `{model}` |
|
||||
| GET | `/api/chat/ps` | Currently loaded (hot) models |
|
||||
| POST | `/api/chat/warm` | Pre-load model into VRAM `{model}` → 202 |
|
||||
|
||||
## Quick Capture
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/api/quick-capture` | Classify + create item from natural language `{text}` → `{success, type, message, data}` |
|
||||
|
||||
## Search
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/search` | Semantic + keyword search across notes and tasks. Params: `q`, `type` (`note`/`task`/`all`), `limit` |
|
||||
|
||||
## Journal
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/journal/config` | Get journal configuration (locations, temp_unit, prep schedule) |
|
||||
| PUT | `/api/journal/config` | Save journal configuration; live-reschedules the prep job |
|
||||
| GET | `/api/journal/today` | Get/create today's journal conversation + messages |
|
||||
| GET | `/api/journal/day/:iso` | Get a specific day's journal conversation (read-only) |
|
||||
| GET | `/api/journal/days` | List dates with journal content, newest first |
|
||||
| POST | `/api/journal/trigger-prep` | Force-regenerate today's prep (or `{date}` for a specific day) |
|
||||
| GET | `/api/journal/weather` | Cached weather rows; auto-refreshes stale rows in the background |
|
||||
| GET | `/api/journal/weather/current` | Live current conditions for the primary configured location |
|
||||
| POST | `/api/journal/weather/refresh` | Manual refresh of all configured locations |
|
||||
| POST | `/api/journal/weather/geocode` | Geocode place name `{query}` → `{lat, lon, label}` |
|
||||
| POST | `/api/journal/moments/:id/update` | Update a recorded moment |
|
||||
| DELETE | `/api/journal/moments/:id` | Delete a moment |
|
||||
|
||||
## Settings
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/settings` | All settings as `{key: value}` |
|
||||
| PUT | `/api/settings` | Update settings `{key: value, ...}` |
|
||||
| GET | `/api/settings/models` | Installed models + defaults |
|
||||
| GET | `/api/settings/search` | Proxy SearXNG search (params: `q`) |
|
||||
|
||||
## API Keys
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/api-keys` | List user's API keys |
|
||||
| POST | `/api/api-keys` | Create key `{name, scope}` → `{key, ...}` (key shown once) |
|
||||
| DELETE | `/api/api-keys/:id` | Revoke key |
|
||||
|
||||
## Scribe MCP
|
||||
|
||||
The MCP tool surface is served at `POST /mcp` (streamable HTTP, Bearer auth) by
|
||||
the in-app server in `src/scribe/mcp/`. It is not a REST surface — see
|
||||
[API Keys and Scribe MCP](api-keys-and-mcp.md) for client configuration.
|
||||
| GET / POST | `/api/groups` | List / create groups |
|
||||
| GET / PATCH / DELETE | `/api/groups/:id` | Read / update / delete |
|
||||
| GET / POST | `/api/groups/:id/members` | List / add members `{user_id, role}` |
|
||||
| PATCH / DELETE | `/api/groups/:id/members/:uid` | Update role / remove |
|
||||
|
||||
## Notifications
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/notifications` | List notifications |
|
||||
| GET | `/api/notifications` | List in-app notifications |
|
||||
| GET | `/api/notifications/count` | Unread count |
|
||||
| POST | `/api/notifications/:id/read` | Mark read |
|
||||
| POST | `/api/notifications/:id/read` | Mark one read |
|
||||
| POST | `/api/notifications/read-all` | Mark all read |
|
||||
|
||||
## Push
|
||||
## Profile and Settings
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/push/vapid-public-key` | VAPID public key for subscription |
|
||||
| POST | `/api/push/subscribe` | Register push subscription |
|
||||
| DELETE | `/api/push/subscribe` | Unregister push subscription |
|
||||
| GET / PUT | `/api/profile` | Read / update the per-user profile |
|
||||
| GET / PUT | `/api/settings` | Read / update key-value settings |
|
||||
| GET | `/api/settings/search` | SearXNG configuration status |
|
||||
|
||||
## Images
|
||||
## API keys
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/images/:id` | Serve cached image **(no auth required — IDs are opaque SHA-256)** |
|
||||
| GET / POST | `/api/api-keys` | List / create `fmcp_` keys (key shown once) |
|
||||
| DELETE | `/api/api-keys/:id` | Revoke a key |
|
||||
|
||||
## Users
|
||||
## Plugin (Claude Code)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/users/search` | Search users by username/email prefix (param: `q`, min 2 chars, excludes self) |
|
||||
| GET | `/api/plugin/context` | SessionStart context payload (rules + active-project) |
|
||||
| GET | `/api/plugin/retrieve` | Title-first knowledge-injection candidates |
|
||||
| GET | `/api/plugin/processes` | Stored Processes for skill-stub sync |
|
||||
| GET / PUT | `/api/plugin/marketplace-url` | Read / set the plugin marketplace URL |
|
||||
|
||||
## Export
|
||||
## Dashboard, Export, Trash, Users
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/export` | Export data. Params: `format=markdown` (ZIP with `.md` + YAML frontmatter) or `format=json` |
|
||||
| GET | `/api/dashboard` | Home dashboard payload |
|
||||
| GET | `/api/export` | Personal export (`format=markdown` ZIP or `format=json`) |
|
||||
| GET | `/api/trash` | List trashed items, grouped by delete batch |
|
||||
| POST | `/api/trash/:batch/restore` | Restore a batch |
|
||||
| DELETE | `/api/trash/:batch` | Purge a batch (irreversible) |
|
||||
| GET | `/api/users/search` | Search users by prefix (for sharing) |
|
||||
|
||||
## Admin
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/admin/backup` | Export backup (`?scope=user` for own data; full requires admin) |
|
||||
| POST | `/api/admin/restore` | Restore from JSON backup |
|
||||
| GET | `/api/admin/users` | List all users |
|
||||
| DELETE | `/api/admin/users/:id` | Delete user (cannot delete self) |
|
||||
| GET | `/api/admin/registration` | Get registration open/closed state |
|
||||
| PUT | `/api/admin/registration` | Toggle registration `{open: bool}` |
|
||||
| POST | `/api/admin/invitations` | Create invitation `{email}` → sends email |
|
||||
| GET | `/api/admin/invitations` | List pending invitations |
|
||||
| DELETE | `/api/admin/invitations/:id` | Revoke invitation |
|
||||
| GET | `/api/admin/logs` | Log entries. Params: `category`, `user_id`, `search`, `date_from`, `date_to`, `limit`, `offset` |
|
||||
| GET | `/api/admin/logs/stats` | Log category counts |
|
||||
| GET | `/api/admin/base-url` | Get base URL setting |
|
||||
| PUT | `/api/admin/base-url` | Set base URL `{base_url}` |
|
||||
| GET | `/api/admin/smtp` | Get SMTP config (password masked) |
|
||||
| PUT | `/api/admin/smtp` | Save SMTP config |
|
||||
| POST | `/api/admin/smtp/test` | Send test email `{recipient}` |
|
||||
| GET | `/api/admin/backup` | Export backup (format v4; `?scope=user` for own data) |
|
||||
| POST | `/api/admin/restore` | Restore from a backup |
|
||||
| GET / DELETE | `/api/admin/users` · `/api/admin/users/:id` | List / delete users |
|
||||
| GET / PUT | `/api/admin/registration` | Get / toggle registration |
|
||||
| GET / PUT | `/api/admin/smtp` · POST `/api/admin/smtp/test` | SMTP config + test email |
|
||||
| GET | `/api/admin/logs` · `/api/admin/logs/stats` | Log entries + category counts |
|
||||
| GET / PUT | `/api/admin/base-url` | Get / set the public base URL |
|
||||
| GET / PUT | `/api/admin/db-maintenance` (+ `/health`, POST `/run`) | VACUUM schedule, health, manual run |
|
||||
| POST / GET / DELETE | `/api/admin/invitations` (+ `/:id`) | Create / list / revoke invite links |
|
||||
|
||||
## Scribe MCP
|
||||
|
||||
Claude clients connect to the built-in MCP server at `POST /mcp` (streamable HTTP,
|
||||
Bearer auth with an `fmcp_` key), served by `src/scribe/mcp/`. It is not a REST
|
||||
surface — it exposes the same data as typed tools (`create_note`, `create_task`,
|
||||
`start_planning`, `search`, `enter_project`, `list_always_on_rules`, …) with
|
||||
server-level usage guidance delivered in the MCP `instructions` block. See
|
||||
[API Keys & MCP](api-keys-and-mcp.md).
|
||||
|
||||
+118
-105
@@ -1,155 +1,168 @@
|
||||
# Features
|
||||
|
||||
Scribe is a self-hosted work system-of-record for software projects, built to be
|
||||
driven by Claude Code. There is **no in-app LLM** — Claude is the sole assistant,
|
||||
reaching Scribe through a built-in MCP endpoint and a bundled Claude Code plugin. The
|
||||
web UI is a clean surface for humans to read and edit the same data.
|
||||
|
||||
## Notes
|
||||
|
||||
Write in Markdown with a live-preview editor (Tiptap/ProseMirror). Headings, bold, italic, lists, code blocks, and task checklists render inline. A slash-command menu (`/`) inserts common blocks.
|
||||
Write in Markdown with a live-preview editor (Tiptap/ProseMirror). Headings, bold,
|
||||
italic, lists, code blocks, and task checklists render inline. A slash-command menu
|
||||
(`/`) inserts common blocks.
|
||||
|
||||
**Wikilinks** — Link notes with `[[Title]]` or `[[Title|Display Text]]` syntax. Clicking a wikilink navigates to (or auto-creates) the referenced note. The editor suggests existing note titles as candidate links while typing `[[`. Backlinks appear in the note viewer sidebar.
|
||||
- **Wikilinks** — Link notes with `[[Title]]` or `[[Title|Display Text]]`. Clicking
|
||||
navigates to (or auto-creates) the referenced note; the editor suggests existing
|
||||
titles while you type `[[`. Backlinks appear in the note viewer sidebar.
|
||||
- **Tags** — First-class `ARRAY[text]` column with autocomplete. Hierarchical tags
|
||||
(`area/backend`) supported — filtering by `area` matches all `area/*` children.
|
||||
- **Version history** — Every body edit snapshots a version (up to 20 per note).
|
||||
Browse, diff, and restore from the editor's History panel.
|
||||
- **Draft recovery** — In-progress edits persist across page loads and are restored
|
||||
when you reopen a note.
|
||||
- **Convert freely** — Turn a note into a task (sets `status=todo`) or back again.
|
||||
|
||||
**Tags** — First-class `ARRAY[text]` column. Tag autocomplete in the editor sidebar suggests existing tags. Hierarchical tags (`project/webapp`) supported — filtering by `project` matches all `project/*` children. Tags are browsable via the knowledge graph.
|
||||
## Tasks and Issues
|
||||
|
||||
**Version history** — Every body edit snapshots a version (up to 20 per note). Browse and restore from the editor's History panel. Diff view shows changes against the current body.
|
||||
Tasks carry status (`todo` → `in_progress` → `done`/`cancelled`), priority
|
||||
(`none`/`low`/`medium`/`high`), due date, milestone assignment, and a parent task
|
||||
(sub-tasks). Notes and tasks share one model — a task is a note with a status.
|
||||
|
||||
**AI writing assist** — Select a passage or work on the full document. Give an instruction ("make this more concise", "add examples"). The assistant streams a proposal; a diff view shows changes to accept or reject. Drafts persist across page loads.
|
||||
|
||||
**Link suggestions** — The editor detects note titles appearing as plain text in the body and suggests converting them to wikilinks.
|
||||
|
||||
## Tasks
|
||||
|
||||
Tasks carry status (`todo` → `in_progress` → `done`), priority (`none`/`low`/`medium`/`high`), due date, milestone assignment, and a parent task (sub-tasks).
|
||||
|
||||
**Task work logs** — Append progress log entries to a task with optional duration. Time tracking is visible in the task editor sidebar.
|
||||
|
||||
**Sub-tasks** — Any task can have child tasks via `parent_id`. The task viewer shows sub-tasks inline.
|
||||
|
||||
**Convert freely** — Convert a note to a task (sets `status=todo`) or a task back to a note from the viewer toolbar.
|
||||
- **Work logs** — Append timestamped progress entries (with optional duration) to a
|
||||
task without rewriting its body; shown chronologically in the task view.
|
||||
- **Issues** — A task whose `kind` is corrective: a problem you fixed or are fixing,
|
||||
with symptom → root cause → fix in the body. An issue can link the task it arose
|
||||
from and the System(s) it touches.
|
||||
- **Recurring tasks** — An interval or calendar recurrence rule spawns the next
|
||||
occurrence when a task is completed (a background job drains due spawns).
|
||||
- **Sub-tasks** — Any task can have children via `parent_id`; the viewer shows them
|
||||
inline.
|
||||
|
||||
## Projects and Milestones
|
||||
|
||||
**Projects** — Group related notes and tasks. Each project has a title, description, goal, status (`active`/`completed`/`archived`), and a colour.
|
||||
- **Projects** — Group related notes and tasks. Title, description, goal, status
|
||||
(`active`/`paused`/`completed`/`archived`), and a colour.
|
||||
- **Milestones** — Ordered stages within a project. A milestone is also the home of a
|
||||
**plan** — its body holds the design (Goal/Approach/Verification) and its child
|
||||
tasks are the steps. Completion percentage is shown on the project page.
|
||||
- **Kanban view** — `/projects/:id` groups tasks by milestone in a column layout with
|
||||
status-advance buttons on the cards.
|
||||
|
||||
**Milestones** — Ordered stages within a project. Tasks are assigned to milestones. Milestone completion percentage shown on the project page.
|
||||
## Systems
|
||||
|
||||
**Kanban view** — `/projects/:id` groups tasks by milestone in a kanban-style column layout with status-advance buttons directly on cards (→ advance, ✓ complete).
|
||||
A **System** is a per-project, reusable, self-describing subsystem or area (e.g.
|
||||
"auth", "billing"). Associate any note, task, or issue with a System so research,
|
||||
build-work, and fixes for the same area line up and recurring problem-spots surface.
|
||||
|
||||
**Project Workspace** — `/workspace/:projectId` opens a three-panel environment (tasks / chat / notes) locked to a project. The AI assistant creates and updates content directly in the workspace; new notes auto-load in the editor and the task list refreshes automatically after tool calls.
|
||||
## Rules and Rulebooks
|
||||
|
||||
Scribe stores the operator's engineering and workflow **rules** so Claude follows them
|
||||
across sessions.
|
||||
|
||||
- **Rulebooks → topics → rules** — Rules are grouped by topic inside a rulebook.
|
||||
- **Always-on rules** — A rulebook can be flagged always-on; its rules load at the
|
||||
start of every session through the plugin's push channel.
|
||||
- **Per-project scope** — A project subscribes to rulebooks, and can add
|
||||
project-scoped rules or suppress individual inherited rules/topics.
|
||||
|
||||
## Stored Processes
|
||||
|
||||
Reusable saved prompts (a note with `note_type=process`) — e.g. a drift-audit or a
|
||||
DRY pass. The bundled plugin syncs each Process into a local Claude Code skill stub
|
||||
(`/scribe:sync`) that auto-surfaces by relevance and fetches the live procedure on
|
||||
demand.
|
||||
|
||||
## Search and Knowledge Injection
|
||||
|
||||
- **Semantic search** — pgvector-backed similarity search over notes and tasks
|
||||
(in-process `fastembed` embeddings; no external model).
|
||||
- **Proactive knowledge-injection** — the plugin's `UserPromptSubmit` hook surfaces a
|
||||
short, high-confidence menu of maybe-relevant note *titles* into Claude's context
|
||||
each turn; Claude pulls a full body only when it judges it relevant. Gated so it
|
||||
stays quiet on most turns and never repeats within a session.
|
||||
|
||||
## Knowledge Graph
|
||||
|
||||
`/graph` renders all notes, tasks, and tags as a D3 force-directed graph. Tag nodes cluster notes that share tags; invisible project hub nodes attract project members. Physics controls: repulsion, link distance, link strength, hub pull, gravity. Click any node to open a slide-in peek panel. Click a tag node to filter the notes list.
|
||||
`/graph` renders notes, tasks, and tags as a D3 force-directed graph. Tag nodes
|
||||
cluster notes that share tags; invisible project hubs attract project members. Physics
|
||||
controls (repulsion, link distance/strength, hub pull, gravity); click a node to peek,
|
||||
click a tag to filter.
|
||||
|
||||
## AI Chat
|
||||
## Claude via MCP and the plugin
|
||||
|
||||
Full conversation history with SSE streaming. Features:
|
||||
- **RAG** — Semantically relevant notes (≥ 0.60 cosine similarity) auto-injected as context. Notes 0.45–0.60 shown in sidebar as "Suggested."
|
||||
- **Attach notes** — Paperclip icon to include specific notes in context.
|
||||
- **RAG scope chip** — Pill above the input bar shows the current note scope. Click to switch: "Orphan notes only" (default — project notes stay out of general chat), any active project, or "All notes." Scope is persisted per conversation. The AI can also call `search_projects` and `set_rag_scope` mid-conversation to switch scope automatically; the chip pulses when this happens.
|
||||
- **Tool calls** — The assistant can create/update notes, tasks, projects, milestones, search the web, check weather, read RSS, query calendar events, and more. Tool calls display inline with confirm/deny for creates.
|
||||
- **Thinking mode** — Toggle extended reasoning for complex questions.
|
||||
- **Abort** — Stop button cancels in-flight generation.
|
||||
- **Message queue** — Messages sent while generation is in progress are queued and drained sequentially.
|
||||
- **Save to note** — Save any assistant reply directly as a note.
|
||||
- **Bulk delete** — Select and delete multiple conversations.
|
||||
- **Retention** — Conversations auto-pruned after configurable days (default 90).
|
||||
The whole store is reachable by Claude through a built-in **MCP endpoint at `/mcp`**
|
||||
(Bearer-auth with an API key). The **Scribe Claude Code plugin** (shipped in this
|
||||
repo) wires it up:
|
||||
|
||||
## Daily Journal
|
||||
- a `SessionStart` hook that injects the operator's always-on rules + active-project
|
||||
context so Scribe surfaces without being asked (fail-open if Scribe is unreachable);
|
||||
- universal process-skills — writing-plans, systematic-debugging, verification,
|
||||
brainstorming — that route their output into Scribe;
|
||||
- your saved Processes auto-surfaced as skills.
|
||||
|
||||
`/journal` is a conversational daily surface — each day is a chat-style conversation seeded with an LLM-generated daily prep as the first assistant message. The prep pulls together today's tasks, calendar events, weather, recent moments, and active projects in flowing prose, then invites the user to continue the conversation throughout the day.
|
||||
|
||||
**Schedule** — Daily prep generates at a configurable time (default 5:00am). The "day rollover hour" controls when the journal switches to a new day (default 4am — late-night entries 1–3am still count as the previous day). Scheduler catches up missed runs on startup.
|
||||
|
||||
**Right rail** — The journal view shows current weather conditions and upcoming events for the next two weeks alongside the conversation. Both surfaces draw from the same data the prep references.
|
||||
|
||||
**Configuration** — Settings → Profile:
|
||||
- *Locations* section: home and work place-name inputs (geocoded on blur via Nominatim) and a temperature unit toggle (C/F)
|
||||
- *Journal* section: prep auto-generate toggle, prep generation time, day rollover hour
|
||||
- *About You* / *Interests* / *Work Schedule* / *Response Preferences* feed personalization into the prep's system prompt
|
||||
|
||||
**Weather** — Location-based forecast via Open-Meteo. Up to two named locations (home, work). Cached rows auto-refresh in the background when the journal page loads.
|
||||
|
||||
**What the assistant has learned** — The assistant maintains a per-user observation log + consolidated summary, generated from journal and chat conversations. The summary is included in the journal's system prompt so the daily prep can reference what it knows about you over time.
|
||||
|
||||
## Web Research
|
||||
|
||||
The assistant can search the web (SearXNG) and fetch pages, synthesising findings into a structured multi-note research output: an index note with an executive summary and links to focused section notes. Each section covers a distinct aspect of the topic with cited sources. Falls back to a single note when outline generation fails. A lightweight `search_web` tool answers quick questions inline without saving. Requires `SEARXNG_URL` to be configured.
|
||||
|
||||
## Calendar
|
||||
|
||||
`/calendar` shows a full FullCalendar view (month, week, day). Click an empty slot to create an event; click an existing event to edit or delete it via a slide-over panel.
|
||||
|
||||
**Internal events store** — Events are stored in the app database (`events` table), making them available without any external calendar. Fields: title, description, start/end datetime, all-day toggle, location, colour.
|
||||
|
||||
**AI tools** — `create_event`, `list_events`, `search_events`, `update_event`, `delete_event` all operate on the internal store. Tool-call result cards in chat are clickable and open the same EventSlideOver for editing.
|
||||
|
||||
**HomeView widget** — The dashboard shows today's and the next 7 days' events as clickable cards above the hero project.
|
||||
|
||||
**CalDAV sync (optional)** — Connect an external CalDAV server (Nextcloud, Radicale, etc.) in Settings → Integrations. Events sync bidirectionally via a `caldav_uid` field.
|
||||
See [API Keys & MCP](api-keys-and-mcp.md).
|
||||
|
||||
## Sharing and Collaboration
|
||||
|
||||
**Share** — Share any project or note/task with users or groups at `viewer`/`editor`/`admin` permission levels. Share button in the viewer/project toolbar opens a dialog.
|
||||
- **Share** — Share any project, note, or task with users or groups at
|
||||
`viewer`/`editor`/`admin` levels from the viewer/project toolbar.
|
||||
- **Groups** — Admins create platform-wide groups and assign `member`/`owner` roles;
|
||||
share a resource with a group in one action.
|
||||
- **Shared with me** — `/shared` lists incoming shares with permission badges.
|
||||
- **Notifications** — An in-app bell (unread count, polled) fires when a project or
|
||||
note is shared with you or you're added to a group.
|
||||
|
||||
**Groups** — Admins create platform-wide groups and assign users `member`/`owner` roles. Share a resource with a group in one action.
|
||||
|
||||
**Shared with me** — `/shared` lists all incoming shared projects and notes with permission badges.
|
||||
|
||||
**Notifications** — Bell icon in nav shows unread count (60s polling). Notifications generated for: project shared, note shared, added to group. Click navigates to the resource.
|
||||
|
||||
**Push notifications** — Web Push (VAPID) notifies when AI generation completes, even in another tab. Works over HTTPS only. Configurable per-user.
|
||||
|
||||
## Quick Capture
|
||||
|
||||
Quick capture from the Android app routes to the intent classifier. It creates notes, tasks, or projects based on content — using the user's configured model, not the hardcoded default.
|
||||
Every read and mutation is scoped by owner + direct shares + group shares.
|
||||
|
||||
## Data Export and Backup
|
||||
|
||||
- **Personal export** — Settings → Data: download all notes/tasks as a Markdown ZIP (with YAML frontmatter) or JSON array.
|
||||
- **Admin backup** — Full application backup (version 2): includes projects, milestones, task logs, AI drafts, note versions, push subscriptions. ID remapping on restore for cross-instance migration.
|
||||
- **Personal export** — download all your notes/tasks as a Markdown ZIP (YAML
|
||||
frontmatter) or a JSON array.
|
||||
- **Admin backup** — full application backup/restore (format v4) with ID remapping on
|
||||
restore for cross-instance migration.
|
||||
|
||||
## PWA
|
||||
## Progressive Web App
|
||||
|
||||
Installable as a desktop or mobile app. Service worker caches the shell; push notifications are suppressed when the relevant tab is already focused. Works over HTTPS only in Firefox.
|
||||
Installable as a desktop or mobile app; a service worker caches the shell.
|
||||
|
||||
## Authentication
|
||||
|
||||
Native email + password, plus optional **OIDC** sign-in (Authentik, Keycloak, etc.)
|
||||
that links to a matching local account. Invite links, a registration toggle, password
|
||||
reset, and session invalidation are included. See [SSO / OAuth](sso-oauth.md).
|
||||
|
||||
## Settings
|
||||
|
||||
Settings are tabbed:
|
||||
|
||||
| Tab | Contents |
|
||||
|-----|----------|
|
||||
| General | Assistant name, default model, model management (pull/delete) |
|
||||
| General | Instance preferences (key/value) |
|
||||
| Account | Email change, password change, session invalidation |
|
||||
| Notifications | Push notification subscription, journal prep push toggle |
|
||||
| Profile | About you, response preferences, interests, work schedule, locations + temperature unit, journal prep schedule, learned observations |
|
||||
| Integrations | CalDAV configuration, SearXNG status |
|
||||
| Data | Personal export, backup/restore (admin) |
|
||||
| API Keys | Create/revoke API keys, Fable MCP download and install |
|
||||
| Config (admin) | Base URL, SMTP, OIDC settings |
|
||||
| Profile | Per-user profile fields |
|
||||
| Integrations | SearXNG status |
|
||||
| Data | Personal export; backup / restore (admin) |
|
||||
| API Keys | Create/revoke `fmcp_` keys for the MCP endpoint |
|
||||
| Config (admin) | Base URL, SMTP, DB-maintenance schedule |
|
||||
| Users (admin) | User list, invite links, registration toggle |
|
||||
| Logs (admin) | Error, audit, and usage logs with search |
|
||||
| Groups (admin) | Create/manage groups and membership |
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Email integration (read/send via IMAP/SMTP tools in chat)
|
||||
- Session invalidation on user deletion
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `g` + `h` | Go to Home |
|
||||
| `g` + `n` | Go to Notes |
|
||||
| `g` + `t` | Go to Tasks |
|
||||
| `g` + `p` | Go to Projects |
|
||||
| `g` + `c` | Go to Chat |
|
||||
| `g` (bare) | Go to Graph |
|
||||
| `g` + `h` | Home (dashboard) |
|
||||
| `g` + `n` | Notes |
|
||||
| `g` + `t` | Knowledge (tasks) |
|
||||
| `g` + `p` | Projects |
|
||||
| `g` + `r` | Rulebooks |
|
||||
| `g` (bare) | Graph |
|
||||
| `g` + `x` | Trash |
|
||||
| `n` | New note |
|
||||
| `t` | New task |
|
||||
| `c` | Focus chat input |
|
||||
| `e` | Edit current item |
|
||||
| `/` | Search |
|
||||
| `/` | Focus search |
|
||||
| `?` | Show shortcuts panel |
|
||||
| `j` / `k` | Navigate list items |
|
||||
| `Enter` | Open selected item |
|
||||
| `Escape` | Close panel / blur / go home (progressive) |
|
||||
| `e` | Edit current item |
|
||||
| `Esc` | Close panel / blur / go home (progressive) |
|
||||
| `Ctrl+S` | Save in editor |
|
||||
|
||||
+4
-11
@@ -81,7 +81,6 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
||||
case "p": router.push("/projects"); break;
|
||||
case "r": router.push("/rules"); break;
|
||||
case "g": router.push("/graph"); break;
|
||||
case "l": router.push("/calendar"); break;
|
||||
case "x": router.push("/trash"); break;
|
||||
}
|
||||
return;
|
||||
@@ -190,12 +189,6 @@ onUnmounted(() => {
|
||||
<kbd class="shortcut-key">p</kbd>
|
||||
<span class="shortcut-desc">Projects</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">g</kbd>
|
||||
<span class="shortcut-key-sep">+</span>
|
||||
<kbd class="shortcut-key">l</kbd>
|
||||
<span class="shortcut-desc">Calendar</span>
|
||||
</div>
|
||||
<div class="shortcut-row">
|
||||
<kbd class="shortcut-key">g</kbd>
|
||||
<span class="shortcut-key-sep">+</span>
|
||||
@@ -262,7 +255,7 @@ onUnmounted(() => {
|
||||
z-index: 9999;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
border-radius: 0 0 4px 4px;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
@@ -307,7 +300,7 @@ onUnmounted(() => {
|
||||
.shortcuts-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
|
||||
background: var(--color-overlay);
|
||||
z-index: 9000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -316,8 +309,8 @@ onUnmounted(() => {
|
||||
.shortcuts-panel {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
box-shadow: 0 8px 32px var(--color-shadow, rgba(0,0,0,0.2));
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 8px 32px var(--color-shadow);
|
||||
width: min(420px, 92vw);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -374,72 +374,6 @@ export async function apiStreamPost(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Calendar events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface EventEntry {
|
||||
id: number;
|
||||
uid: string;
|
||||
title: string;
|
||||
start_dt: string;
|
||||
end_dt: string | null;
|
||||
all_day: boolean;
|
||||
description: string;
|
||||
location: string;
|
||||
color: string;
|
||||
recurrence: string | null;
|
||||
caldav_uid: string;
|
||||
project_id: number | null;
|
||||
user_id: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface EventCreatePayload {
|
||||
title: string;
|
||||
start_dt: string;
|
||||
end_dt?: string;
|
||||
all_day?: boolean;
|
||||
description?: string;
|
||||
location?: string;
|
||||
color?: string;
|
||||
recurrence?: string;
|
||||
project_id?: number;
|
||||
}
|
||||
|
||||
export interface EventUpdatePayload {
|
||||
title?: string;
|
||||
start_dt?: string;
|
||||
end_dt?: string;
|
||||
all_day?: boolean;
|
||||
description?: string;
|
||||
location?: string;
|
||||
color?: string;
|
||||
recurrence?: string | null;
|
||||
project_id?: number;
|
||||
}
|
||||
|
||||
export async function listEvents(from: string, to: string): Promise<EventEntry[]> {
|
||||
return apiGet<EventEntry[]>(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
|
||||
}
|
||||
|
||||
export async function createEvent(payload: EventCreatePayload): Promise<EventEntry> {
|
||||
return apiPost<EventEntry>('/api/events', payload);
|
||||
}
|
||||
|
||||
export async function getEvent(id: number): Promise<EventEntry> {
|
||||
return apiGet<EventEntry>(`/api/events/${id}`);
|
||||
}
|
||||
|
||||
export async function updateEvent(id: number, payload: EventUpdatePayload): Promise<EventEntry> {
|
||||
return apiPatch<EventEntry>(`/api/events/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteEvent(id: number): Promise<void> {
|
||||
return apiDelete(`/api/events/${id}`);
|
||||
}
|
||||
|
||||
// ─── API Keys ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ApiKeyEntry {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { apiGet } from "@/api/client";
|
||||
import type { ExpectationResponse } from "@/utils/designDrift";
|
||||
|
||||
/** Checkable claims from the rulebook this install designated as its design system.
|
||||
*
|
||||
* `rulebook_id: null` means none has been designated — the normal state for a
|
||||
* fresh install, not an error. The caller shows an explanatory empty state. */
|
||||
export const fetchDesignExpectations = () =>
|
||||
apiGet<ExpectationResponse>("/api/design/expectations");
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Design systems — the stylesheet held as records (milestone #254).
|
||||
*
|
||||
* A design system is a named set of tokens with an optional parent. A system
|
||||
* with no parent is a "family"; one with a parent holds ONLY what it changes,
|
||||
* so "what does this app alter?" is a plain list rather than a diff.
|
||||
*/
|
||||
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "@/api/client";
|
||||
|
||||
export interface DesignSystem {
|
||||
id: number;
|
||||
owner_user_id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Narrative a token table cannot hold: aesthetic, voice, what's out of scope. */
|
||||
guidance: string;
|
||||
parent_id: number | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
/** A token as STORED — one system's own row for it. */
|
||||
export interface DesignToken {
|
||||
id: number;
|
||||
design_system_id: number;
|
||||
name: string;
|
||||
/** Values keyed by mode. `base` applies when no mode is more specific. */
|
||||
value_by_mode: Record<string, string>;
|
||||
group_name: string | null;
|
||||
purpose: string | null;
|
||||
/** WHY it is this value — distinct from `purpose`, which is what it is FOR. */
|
||||
rationale: string | null;
|
||||
/** Literal values this token should be used INSTEAD OF, e.g. ["#fff"].
|
||||
*
|
||||
* How a design system records what a prohibition was trying to say: not
|
||||
* "white is banned" but "write this token instead". Declared rather than
|
||||
* inferred, because a superseded literal and the token's own value are
|
||||
* usually different values and nothing could connect them by matching. */
|
||||
supersedes: string[];
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
export interface Contribution {
|
||||
system_id: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A token after the cascade.
|
||||
*
|
||||
* `contributions` is every system that offered a value, per mode, DEEPEST
|
||||
* FIRST — entry 0 won and the rest were shadowed. `value_by_mode` and
|
||||
* `origin_by_mode` are the winners, provided so the client never has to derive
|
||||
* them (and so it cannot derive them differently).
|
||||
*
|
||||
* Provenance is per MODE because overriding is: a system can own `base` and
|
||||
* inherit `dark` at the same time.
|
||||
*/
|
||||
export interface ResolvedToken {
|
||||
name: string;
|
||||
group_name: string | null;
|
||||
purpose: string | null;
|
||||
rationale: string | null;
|
||||
supersedes: string[];
|
||||
order_index: number;
|
||||
value_by_mode: Record<string, string>;
|
||||
origin_by_mode: Record<string, number>;
|
||||
contributions: Record<string, Contribution[]>;
|
||||
}
|
||||
|
||||
export const fetchDesignSystems = () =>
|
||||
apiGet<{ design_systems: DesignSystem[] }>("/api/design-systems");
|
||||
|
||||
export const fetchDesignSystem = (id: number) =>
|
||||
apiGet<DesignSystem>(`/api/design-systems/${id}`);
|
||||
|
||||
export interface StarterRoleGroup {
|
||||
group: string;
|
||||
description: string;
|
||||
token_count: number;
|
||||
names: string[];
|
||||
}
|
||||
|
||||
/** The starter token ROLES offered at creation — names and purposes, never
|
||||
* values. A default palette would be one install's taste shipped as product
|
||||
* (rule #115), so the values are always the operator's to fill. */
|
||||
export const listStarterRoleGroups = () =>
|
||||
apiGet<{ groups: StarterRoleGroup[]; default_prefix: string }>(
|
||||
"/api/design-systems/starter-roles",
|
||||
);
|
||||
|
||||
export const createDesignSystem = (body: {
|
||||
title: string;
|
||||
description?: string;
|
||||
guidance?: string;
|
||||
parent_id?: number | null;
|
||||
starter_role_groups?: string[];
|
||||
token_prefix?: string;
|
||||
}) => apiPost<DesignSystem>("/api/design-systems", body);
|
||||
|
||||
/** Omit `parent_id` to leave it alone; send `null` to make the system a family. */
|
||||
export const updateDesignSystem = (
|
||||
id: number,
|
||||
body: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
guidance?: string;
|
||||
parent_id?: number | null;
|
||||
},
|
||||
) => apiPatch<DesignSystem>(`/api/design-systems/${id}`, body);
|
||||
|
||||
export const deleteDesignSystem = (id: number) =>
|
||||
apiDelete(`/api/design-systems/${id}`);
|
||||
|
||||
/** The EFFECTIVE set: everything inherited, with this system's on top. */
|
||||
export const fetchResolvedTokens = (id: number) =>
|
||||
apiGet<{ design_system_id: number; tokens: ResolvedToken[] }>(
|
||||
`/api/design-systems/${id}/resolved`,
|
||||
);
|
||||
|
||||
/** This system's OWN tokens — its override set. */
|
||||
export const fetchDesignTokens = (id: number) =>
|
||||
apiGet<{ tokens: DesignToken[] }>(`/api/design-systems/${id}/tokens`);
|
||||
|
||||
export const createDesignToken = (
|
||||
designSystemId: number,
|
||||
body: {
|
||||
name: string;
|
||||
value_by_mode?: Record<string, string>;
|
||||
group_name?: string | null;
|
||||
purpose?: string | null;
|
||||
rationale?: string | null;
|
||||
supersedes?: string[];
|
||||
order_index?: number;
|
||||
},
|
||||
) => apiPost<DesignToken>(`/api/design-systems/${designSystemId}/tokens`, body);
|
||||
|
||||
export const updateDesignToken = (
|
||||
tokenId: number,
|
||||
body: Partial<Omit<DesignToken, "id" | "design_system_id">>,
|
||||
) => apiPatch<DesignToken>(`/api/design-tokens/${tokenId}`, body);
|
||||
|
||||
export const deleteDesignToken = (tokenId: number) =>
|
||||
apiDelete(`/api/design-tokens/${tokenId}`);
|
||||
|
||||
/** Point a project at a design system. `null` clears it. */
|
||||
export const setProjectDesignSystem = (
|
||||
projectId: number,
|
||||
designSystemId: number | null,
|
||||
) =>
|
||||
apiPut<{ project_id: number; design_system_id: number | null }>(
|
||||
`/api/projects/${projectId}/design-system`,
|
||||
{ design_system_id: designSystemId },
|
||||
);
|
||||
|
||||
export interface StylesheetResult {
|
||||
design_system_id: number;
|
||||
/** The master sheet: purpose tokens only, no element or class rules. */
|
||||
css: string;
|
||||
token_count: number;
|
||||
/** Tokens the system names but has no value for yet. */
|
||||
valueless: string[];
|
||||
/** Values declared under more than one name — alias, or one idea twice. */
|
||||
duplicates: Record<string, string[]>;
|
||||
derivation: {
|
||||
/** Tokens computed from others, mapped to what they're computed from. */
|
||||
derived: Record<string, string[]>;
|
||||
/** Formulas pointing at tokens that don't exist — the browser drops these. */
|
||||
unknown_refs: Record<string, string[]>;
|
||||
/** Derivation loops, which resolve to nothing for the same reason. */
|
||||
cycles: string[][];
|
||||
};
|
||||
}
|
||||
|
||||
/** The master CSS sheet a design system generates.
|
||||
*
|
||||
* Purpose tokens only. Components (buttons, tables, input schemes) are
|
||||
* snippets that reference these names, so a value is stated once and reused
|
||||
* rather than restated per element. */
|
||||
export const fetchStylesheet = (id: number) =>
|
||||
apiGet<StylesheetResult>(`/api/design-systems/${id}/stylesheet`);
|
||||
|
||||
export interface SnippetFinding {
|
||||
snippet_id: number;
|
||||
title: string;
|
||||
/** References that resolve against the sheet. */
|
||||
used: string[];
|
||||
/** `var(--x)` where the system has no `--x` — renders as nothing at all. */
|
||||
unknown: string[];
|
||||
/** Literals the sheet says to stop writing, paired with what to write. */
|
||||
superseded_literals: { literal: string; use_instead: string }[];
|
||||
/** Custom properties the snippet mints for itself instead of reusing. */
|
||||
local_definitions: string[];
|
||||
}
|
||||
|
||||
export interface SnippetCheck {
|
||||
design_system_id: number;
|
||||
checked: number;
|
||||
/** Only snippets with something to act on; clean ones are omitted. */
|
||||
findings: SnippetFinding[];
|
||||
}
|
||||
|
||||
/** Which recorded snippets disagree with this design system's sheet. */
|
||||
export const checkSnippets = (id: number) =>
|
||||
apiGet<SnippetCheck>(`/api/design-systems/${id}/snippet-check`);
|
||||
@@ -0,0 +1,212 @@
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
|
||||
/** One canonical location of a reusable thing. A snippet that unifies several
|
||||
* one-offs carries several — one per call site. */
|
||||
export interface SnippetLocation {
|
||||
repo: string;
|
||||
path: string;
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
/** Structured fields parsed out of a snippet note (mirrors the backend
|
||||
* `parse_snippet_fields` — see services/snippets.py). `repo`/`path`/`symbol`
|
||||
* mirror the first location for back-compat; `locations` is the full list. */
|
||||
export interface SnippetFields {
|
||||
name: string;
|
||||
when_to_use: string;
|
||||
signature: string;
|
||||
language: string;
|
||||
repo: string;
|
||||
path: string;
|
||||
symbol: string;
|
||||
locations: SnippetLocation[];
|
||||
/** The snippets folded into this one by merge, oldest first. Read-only: merge
|
||||
* is the only thing that adds to it, and an edit carries it forward.
|
||||
*
|
||||
* Each entry records what THAT source contributed — never what the survivor
|
||||
* already had — which is what lets un-merge subtract exactly. An entry with
|
||||
* no `locations`/`tags` predates that attribution and cannot be un-merged. */
|
||||
merged_from: { id: number; locations?: SnippetLocation[]; tags?: string[] }[];
|
||||
code: string;
|
||||
}
|
||||
|
||||
/** A full snippet record: the note dict plus the parsed `snippet` sub-object,
|
||||
* as returned by the backend `snippet_to_dict`. */
|
||||
export interface Snippet {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
tags: string[];
|
||||
note_type: string;
|
||||
project_id: number | null;
|
||||
permission?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
snippet: SnippetFields;
|
||||
systems?: { id: number; name: string }[];
|
||||
/** Set when another user owns this record; `owner` is their username and
|
||||
* `permission` your access level on it. */
|
||||
shared?: boolean;
|
||||
owner?: string | null;
|
||||
}
|
||||
|
||||
/** How often a record was put in front of an agent versus actually opened.
|
||||
* A high `surfaced_count` with `pull_count: 0` is dead weight — it occupies a
|
||||
* slot in every future auto-inject menu while never being used. */
|
||||
export interface SnippetUsage {
|
||||
surfaced_count: number;
|
||||
pull_count: number;
|
||||
last_surfaced_at: string | null;
|
||||
last_pulled_at: string | null;
|
||||
}
|
||||
|
||||
/** Result of the last drift check — does the recorded location and code still
|
||||
* match source? The check runs agent-side (Scribe has no checkout); this is the
|
||||
* remembered verdict. `current` is false once the snippet has been edited since
|
||||
* the check, at which point the verdict describes code that's no longer there. */
|
||||
export interface SnippetVerification {
|
||||
status: "ok" | "missing" | "moved" | "changed" | "unverified";
|
||||
current: boolean;
|
||||
checked_at: string | null;
|
||||
detail?: string | null;
|
||||
path?: string | null;
|
||||
needs_attention?: boolean;
|
||||
}
|
||||
|
||||
/** Lightweight list item from the knowledge preview feed. Note: the `snippet`
|
||||
* field here is a truncated *body preview* (the knowledge feed's naming), not
|
||||
* the parsed fields above. */
|
||||
export interface SnippetListItem {
|
||||
id: number;
|
||||
title: string;
|
||||
tags: string[];
|
||||
note_type: string;
|
||||
snippet: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** Present only when the record belongs to someone else — a suggestion from
|
||||
* them, not one of your own. Absent means it's yours. */
|
||||
shared?: boolean;
|
||||
owner?: string | null;
|
||||
/** The recorded language, when one was given. Projected from the `data`
|
||||
* mirror so lists can show it without parsing the body — and so a prior-art
|
||||
* hit can be flagged as being in a DIFFERENT language than the file being
|
||||
* written, which is a shape to adapt rather than code to paste. */
|
||||
language?: string;
|
||||
/** Always present from the backend, zero-filled for records with no events. */
|
||||
usage?: SnippetUsage;
|
||||
/** Present on the detail record; the list feed carries it when a check has
|
||||
* been recorded. */
|
||||
verification?: SnippetVerification;
|
||||
}
|
||||
|
||||
/** Create/update payload — discrete fields the backend serializes into the
|
||||
* note (title/body/tags). Empty strings are applied; omitted keys are left
|
||||
* unchanged on update. */
|
||||
export interface SnippetInput {
|
||||
name: string;
|
||||
code: string;
|
||||
language?: string;
|
||||
signature?: string;
|
||||
when_to_use?: string;
|
||||
locations?: SnippetLocation[];
|
||||
tags?: string[];
|
||||
project_id?: number | null;
|
||||
system_ids?: number[];
|
||||
/** Create only: record it even though a near-duplicate already exists. */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/** `repo`/`path`/`symbol` are the reverse lookup — "what already lives here?"
|
||||
* They must all match the same recorded location; `path` also matches anything
|
||||
* beneath it. Combinable with `q`. */
|
||||
export async function listSnippets(
|
||||
params: {
|
||||
q?: string;
|
||||
tag?: string;
|
||||
projectId?: number | null;
|
||||
repo?: string;
|
||||
path?: string;
|
||||
symbol?: string;
|
||||
/** Drift check: "attention" | "ok" | "unverified" | "drifted" | a status. */
|
||||
verification?: string;
|
||||
} = {},
|
||||
): Promise<{ snippets: SnippetListItem[]; total: number }> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.q) qs.set("q", params.q);
|
||||
if (params.tag) qs.set("tag", params.tag);
|
||||
if (params.projectId) qs.set("project_id", String(params.projectId));
|
||||
if (params.repo) qs.set("repo", params.repo);
|
||||
if (params.path) qs.set("path", params.path);
|
||||
if (params.symbol) qs.set("symbol", params.symbol);
|
||||
if (params.verification) qs.set("verification", params.verification);
|
||||
const query = qs.toString();
|
||||
return apiGet(`/api/snippets${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function getSnippet(id: number): Promise<Snippet> {
|
||||
return apiGet(`/api/snippets/${id}`);
|
||||
}
|
||||
|
||||
export async function createSnippet(data: SnippetInput): Promise<Snippet> {
|
||||
return apiPost("/api/snippets", data);
|
||||
}
|
||||
|
||||
export async function updateSnippet(
|
||||
id: number,
|
||||
data: Partial<SnippetInput>,
|
||||
): Promise<Snippet> {
|
||||
return apiPatch(`/api/snippets/${id}`, data);
|
||||
}
|
||||
|
||||
export async function deleteSnippet(id: number): Promise<void> {
|
||||
return apiDelete(`/api/snippets/${id}`);
|
||||
}
|
||||
|
||||
/** A set of snippets that resemble each other closely enough to be worth
|
||||
* merging. Grouping is transitive, so a set can hold members that don't
|
||||
* directly resemble each other — read it as a proposal, not a verdict. */
|
||||
export interface DuplicateGroup {
|
||||
note_ids: number[];
|
||||
snippets: { id: number; title: string }[];
|
||||
/** The strongest resemblance within the set — how confident the suggestion is. */
|
||||
top_score: number;
|
||||
}
|
||||
|
||||
/** Near-duplicates already in the record. The create gate prevents new ones and
|
||||
* merge cures the ones you point it at; this is what finds them. */
|
||||
export async function findDuplicateSnippets(
|
||||
threshold?: number,
|
||||
): Promise<{ groups: DuplicateGroup[]; threshold: number }> {
|
||||
const qs = threshold ? `?threshold=${threshold}` : "";
|
||||
return apiGet(`/api/snippets/duplicates${qs}`);
|
||||
}
|
||||
|
||||
/** Record a drift-check verdict. The check itself runs where the code is — an
|
||||
* agent with the working tree — since Scribe has no checkout. This stores what
|
||||
* was found, and is how the UI clears a stale marker after a manual fix. */
|
||||
export async function verifySnippet(
|
||||
id: number,
|
||||
verdict: { status: string; detail?: string; path?: string },
|
||||
): Promise<Snippet> {
|
||||
return apiPost(`/api/snippets/${id}/verify`, verdict);
|
||||
}
|
||||
|
||||
/** Pull one source back out of a merged survivor: restores it and strips exactly
|
||||
* what it contributed. Also repairs a half-undone merge — a source restored
|
||||
* from the trash by hand leaves the survivor still claiming its call sites. */
|
||||
export async function unmergeSnippet(
|
||||
survivorId: number,
|
||||
sourceId: number,
|
||||
): Promise<{ survivor: Snippet; restored: Snippet | null }> {
|
||||
return apiPost(`/api/snippets/${survivorId}/unmerge`, { source_id: sourceId });
|
||||
}
|
||||
|
||||
/** Unify `sourceIds` into the canonical snippet `targetId`. Returns the merged
|
||||
* survivor plus `merged_ids` — the sources actually folded in and trashed. */
|
||||
export async function mergeSnippets(
|
||||
targetId: number,
|
||||
sourceIds: number[],
|
||||
): Promise<Snippet & { merged_ids: number[] }> {
|
||||
return apiPost(`/api/snippets/${targetId}/merge`, { source_ids: sourceIds });
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||
|
||||
export interface System {
|
||||
id: number;
|
||||
project_id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
color: string | null;
|
||||
status: "active" | "archived";
|
||||
order_index: number;
|
||||
open_issue_count: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export async function listSystems(projectId: number): Promise<System[]> {
|
||||
const data = await apiGet<{ systems: System[] }>(`/api/projects/${projectId}/systems`);
|
||||
return data.systems;
|
||||
}
|
||||
|
||||
export async function createSystem(
|
||||
projectId: number,
|
||||
data: { name: string; description?: string; color?: string },
|
||||
): Promise<System> {
|
||||
return apiPost(`/api/projects/${projectId}/systems`, data);
|
||||
}
|
||||
|
||||
export async function updateSystem(
|
||||
projectId: number,
|
||||
systemId: number,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
description: string;
|
||||
color: string | null;
|
||||
status: "active" | "archived";
|
||||
order_index: number;
|
||||
}>,
|
||||
): Promise<System> {
|
||||
return apiPatch(`/api/projects/${projectId}/systems/${systemId}`, data);
|
||||
}
|
||||
|
||||
export async function deleteSystem(projectId: number, systemId: number): Promise<void> {
|
||||
return apiDelete(`/api/projects/${projectId}/systems/${systemId}`);
|
||||
}
|
||||
|
||||
// Lightweight issue shape returned by the project-issues list endpoint.
|
||||
export interface TaskLike {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
systems?: System[];
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export async function getProjectIssues(
|
||||
projectId: number,
|
||||
openOnly = true,
|
||||
): Promise<TaskLike[]> {
|
||||
const data = await apiGet<{ issues: TaskLike[] }>(
|
||||
`/api/projects/${projectId}/issues?open_only=${openOnly}`,
|
||||
);
|
||||
return data.issues;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/* Shared component styles — the house recipes, in one place.
|
||||
* ===========================================================================
|
||||
*
|
||||
* WHY THIS FILE EXISTS
|
||||
*
|
||||
* `.btn-primary` was defined five times, in five scoped stylesheets, and all
|
||||
* five had drifted: three paddings, three font sizes, three disabled opacities,
|
||||
* and one view with no disabled style at all (#2273). Nothing detected that,
|
||||
* because a scoped duplicate is invisible to every tool — it isn't a rule
|
||||
* violation, isn't a broken reference, and isn't a recorded snippet.
|
||||
*
|
||||
* GEOMETRY LIVES HERE. Every value is a design-system token, so a palette or
|
||||
* scale change moves the buttons rather than stranding a copy that no longer
|
||||
* matches.
|
||||
*
|
||||
* A button is `variant + size`, composed in the template:
|
||||
* btn-primary a page action
|
||||
* btn-primary btn-compact a row action
|
||||
* btn-ghost btn-inline an affordance inside a card
|
||||
* btn-primary btn-block a form's single submitting action
|
||||
* Semantic per-view names (.btn-save, .btn-delete-task, …) were the thing that
|
||||
* drifted, because a name says what a button is FOR and nothing about what it
|
||||
* should look like — so two buttons doing the same job in two views had no
|
||||
* reason to match, and didn't.
|
||||
*
|
||||
* MIGRATION NOTE — this file is deliberately safe to land ahead of the removals.
|
||||
* These are plain selectors (specificity 0,1,0); a Vue `<style scoped>` rule
|
||||
* compiles to `.btn-primary[data-v-…]` (0,2,0) and therefore WINS. So a view
|
||||
* still carrying its own copy is unaffected until that copy is deleted, and
|
||||
* every intermediate state of the migration is coherent.
|
||||
*/
|
||||
|
||||
/* --- the shared shape ---------------------------------------------------- */
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-ghost,
|
||||
.btn-danger,
|
||||
.btn-danger-outline {
|
||||
padding: var(--fs-space-2) var(--fs-space-4); /* 8px 16px */
|
||||
border: none;
|
||||
border-radius: var(--fs-radius-md); /* 8px — the system's button radius */
|
||||
font-family: var(--fs-font-body);
|
||||
font-size: var(--fs-size-label); /* 12px */
|
||||
font-weight: var(--fs-weight-medium); /* 500 — the heaviest the system goes */
|
||||
line-height: var(--fs-leading-body);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background var(--fs-dur-fast) var(--fs-ease),
|
||||
border-color var(--fs-dur-fast) var(--fs-ease),
|
||||
color var(--fs-dur-fast) var(--fs-ease);
|
||||
}
|
||||
|
||||
/* One rule, so a disabled button can never look enabled in one view and
|
||||
* disabled in another — which is exactly what ProjectListView shipped, having
|
||||
* no disabled style at all. */
|
||||
.btn-primary:disabled,
|
||||
.btn-secondary:disabled,
|
||||
.btn-ghost:disabled,
|
||||
.btn-danger:disabled,
|
||||
.btn-danger-outline:disabled {
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary:focus-visible,
|
||||
.btn-secondary:focus-visible,
|
||||
.btn-ghost:focus-visible,
|
||||
.btn-danger:focus-visible,
|
||||
.btn-danger-outline:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
}
|
||||
|
||||
/* --- variants ------------------------------------------------------------ */
|
||||
|
||||
/* The accent is deliberately ABSENT from every filled variant. Action colours
|
||||
* are universal across the family so a Save button looks identical in every
|
||||
* app — the accent is identity, not action. */
|
||||
.btn-primary {
|
||||
background: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.btn-primary:not(:disabled):hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--color-action-secondary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.btn-secondary:not(:disabled):hover {
|
||||
background: var(--color-action-secondary-hover);
|
||||
}
|
||||
|
||||
/* Ghost is an OUTLINE, which is why its border and the tertiary action colour
|
||||
* are the same token rather than two values that happen to match. Hover moves
|
||||
* the BORDER, not the text to the accent — SnippetDetailView tinted the label
|
||||
* with the accent on hover, which the house style reserves for identity and
|
||||
* active state, not for general chrome. */
|
||||
.btn-ghost {
|
||||
background: none;
|
||||
border: var(--fs-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.btn-ghost:not(:disabled):hover {
|
||||
border: var(--fs-border-hover);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
/* Destructive is NOT the error colour: an error is a failure that happened, a
|
||||
* destructive action is one about to happen. Pair with an icon. */
|
||||
.btn-danger {
|
||||
background: var(--color-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.btn-danger:not(:disabled):hover {
|
||||
background: var(--color-action-destructive-hover);
|
||||
}
|
||||
|
||||
/* A bare text button: no fill, no border. The most common shape in the dense
|
||||
* surfaces — a dismiss, a cancel next to a confirm, a clear-search — where a
|
||||
* border would draw a box around something that should read as an action on
|
||||
* the text beside it. Distinct from ghost, which IS a box. */
|
||||
.btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--fs-space-1) var(--fs-space-2);
|
||||
font-family: var(--fs-font-body);
|
||||
font-size: var(--fs-size-tiny);
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: color var(--fs-dur-fast) var(--fs-ease);
|
||||
}
|
||||
.btn-text:not(:disabled):hover { color: var(--color-text); }
|
||||
.btn-text:disabled { opacity: var(--fs-disabled-opacity); cursor: not-allowed; }
|
||||
.btn-text:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
|
||||
|
||||
/* Destructive, outlined — fills on hover. Already existed independently in
|
||||
* three views before this sheet, which is what makes it a variant rather than
|
||||
* a one-off: it is what a delete looks like when it must not shout. */
|
||||
.btn-danger-outline {
|
||||
background: none;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
}
|
||||
.btn-danger-outline:not(:disabled):hover {
|
||||
background: var(--color-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
|
||||
/* --- size modifiers ------------------------------------------------------
|
||||
*
|
||||
* THREE sizes, because the app genuinely has three. Measured across the ~100
|
||||
* bespoke button rules before this scale existed, vertical padding fell into
|
||||
* clusters rather than a spread: ~27 at 0.4–0.45rem, ~28 at 0.25–0.3rem, ~23
|
||||
* at 0.1–0.15rem. Those are three different components — a page action, a row
|
||||
* action, and an affordance living inside a card — that happen to share a name
|
||||
* prefix. Collapsing them to one size would visibly break the card layouts.
|
||||
*
|
||||
* A button carries its size modifier; the DEFAULT (no modifier) is the page
|
||||
* action, which is the one the house style specifies.
|
||||
*/
|
||||
|
||||
/* Row actions: a toolbar, a table row, a list item's controls. */
|
||||
.btn-compact,
|
||||
.btn-small, /* pre-existing spellings, kept so no template churns */
|
||||
.btn-sm {
|
||||
padding: var(--fs-space-1) var(--fs-space-3); /* 4px 12px */
|
||||
font-size: var(--fs-size-tiny);
|
||||
}
|
||||
|
||||
/* Inline affordances: a dismiss ×, a confirm tick, an add-chip — things that
|
||||
* sit INSIDE a line of text or a card and must not disturb its rhythm. Below
|
||||
* the spacing scale's first step on the vertical axis by necessity: 4px of
|
||||
* padding on a 11px label already exceeds the line box these live in. */
|
||||
.btn-inline {
|
||||
padding: 2px var(--fs-space-1); /* 2px 4px */
|
||||
font-size: var(--fs-size-tiny);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Full width, for a form's single submitting action — the auth screens. Width
|
||||
* is orthogonal to size, so it composes: `btn-primary btn-block`. */
|
||||
.btn-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--fs-space-3) var(--fs-space-4); /* 12px 16px — a touch taller,
|
||||
because a full-width button
|
||||
is the page's main action */
|
||||
font-size: var(--fs-size-body-sm);
|
||||
}
|
||||
@@ -34,86 +34,11 @@
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-back:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
/* Save: Moss action-primary per the Hybrid rule. Saving is "operating
|
||||
the software" — not a brand moment. Accent gradient is reserved for
|
||||
Send / empty-state CTAs. */
|
||||
.btn-save {
|
||||
padding: 0.45rem 1.1rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
.btn-save:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
/* Delete: Oxblood action-destructive per Hybrid rule. Should be paired
|
||||
with a Trash icon at the call site to reinforce intent. */
|
||||
.btn-delete {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-delete:hover { background: var(--color-action-destructive-hover); }
|
||||
.btn-assist-toggle {
|
||||
margin-left: auto;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-assist-toggle.active {
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.title-input {
|
||||
padding: 0.4rem 0;
|
||||
border: none;
|
||||
border-bottom: 1.5px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
width: 100%;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.title-input:focus {
|
||||
outline: none;
|
||||
border-bottom-color: var(--color-primary);
|
||||
@@ -155,23 +80,6 @@
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.btn-suggest-tags {
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-suggest-tags:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-suggest-tags:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -187,29 +95,17 @@
|
||||
}
|
||||
.tag-pill:hover:not(:disabled) {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.tag-pill.applied {
|
||||
background: var(--color-success, #2ecc71);
|
||||
border-color: var(--color-success, #2ecc71);
|
||||
color: #fff;
|
||||
background: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
color: var(--fs-text-on-action);
|
||||
cursor: default;
|
||||
}
|
||||
.tag-check {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.btn-dismiss-tags {
|
||||
padding: 0.1rem 0.4rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-dismiss-tags:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ── Assist panel ── */
|
||||
.assist-panel {
|
||||
@@ -237,32 +133,6 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.btn-proofread {
|
||||
padding: 0.3rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-proofread:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.btn-proofread:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-close-assist {
|
||||
padding: 0.1rem 0.4rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.assist-panel-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -342,28 +212,6 @@
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-generate {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-generate:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-clear {
|
||||
padding: 0.4rem 0.9rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Streaming */
|
||||
.assist-streaming-label {
|
||||
@@ -419,14 +267,6 @@
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.btn-toggle-view {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-primary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.diff-view {
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -475,24 +315,6 @@
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.btn-accept {
|
||||
padding: 0.4rem 1rem;
|
||||
background: var(--color-success);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-reject {
|
||||
padding: 0.4rem 1rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-overlay {
|
||||
@@ -537,7 +359,7 @@
|
||||
}
|
||||
.modal-btn-danger {
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -547,8 +369,8 @@
|
||||
z-index: 100;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
background: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
@@ -640,3 +462,96 @@
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Editor button aliases.
|
||||
*
|
||||
* These names are used across six views, so they alias onto the shared variants
|
||||
* rather than every call site being rewritten — the class stays the app's, the
|
||||
* appearance comes from components.css. Same reasoning as .btn-small: a name
|
||||
* already in the templates is cheaper to point somewhere than to replace.
|
||||
*
|
||||
* They are @extend-shaped, which CSS lacks, so each carries the variant's own
|
||||
* declarations. That is the one duplication this migration cannot remove — but
|
||||
* it is duplication of a REFERENCE (a var()), not of a value, so a palette
|
||||
* change still moves everything at once.
|
||||
* ------------------------------------------------------------------------ */
|
||||
|
||||
.btn-accept,
|
||||
.btn-generate,
|
||||
.btn-save {
|
||||
background: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
}
|
||||
.btn-accept:not(:disabled):hover,
|
||||
.btn-generate:not(:disabled):hover,
|
||||
.btn-save:not(:disabled):hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.btn-back,
|
||||
.btn-clear,
|
||||
.btn-reject,
|
||||
.btn-proofread,
|
||||
.btn-suggest-tags {
|
||||
background: none;
|
||||
border: var(--fs-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.btn-back:not(:disabled):hover,
|
||||
.btn-clear:not(:disabled):hover,
|
||||
.btn-reject:not(:disabled):hover,
|
||||
.btn-proofread:not(:disabled):hover,
|
||||
.btn-suggest-tags:not(:disabled):hover {
|
||||
border: var(--fs-border-hover);
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: var(--color-action-destructive);
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
}
|
||||
.btn-delete:not(:disabled):hover {
|
||||
background: var(--color-action-destructive-hover);
|
||||
}
|
||||
|
||||
/* Shared geometry for every alias above. */
|
||||
.btn-accept, .btn-generate, .btn-save, .btn-back, .btn-clear, .btn-reject,
|
||||
.btn-delete, .btn-dismiss-tags, .btn-proofread, .btn-suggest-tags {
|
||||
border-radius: var(--fs-radius-md);
|
||||
font-family: var(--fs-font-body);
|
||||
font-weight: var(--fs-weight-medium);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background var(--fs-dur-fast) var(--fs-ease),
|
||||
border-color var(--fs-dur-fast) var(--fs-ease),
|
||||
color var(--fs-dur-fast) var(--fs-ease);
|
||||
}
|
||||
.btn-accept, .btn-generate, .btn-save, .btn-back, .btn-clear, .btn-reject,
|
||||
.btn-delete {
|
||||
padding: var(--fs-space-2) var(--fs-space-4);
|
||||
font-size: var(--fs-size-label);
|
||||
}
|
||||
.btn-proofread, .btn-suggest-tags {
|
||||
padding: var(--fs-space-1) var(--fs-space-3);
|
||||
font-size: var(--fs-size-tiny);
|
||||
}
|
||||
.btn-dismiss-tags {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
padding: 2px var(--fs-space-1);
|
||||
font-size: var(--fs-size-tiny);
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-dismiss-tags:hover { color: var(--color-text); }
|
||||
|
||||
.btn-accept:disabled, .btn-generate:disabled, .btn-save:disabled,
|
||||
.btn-back:disabled, .btn-clear:disabled, .btn-reject:disabled,
|
||||
.btn-delete:disabled, .btn-proofread:disabled, .btn-suggest-tags:disabled,
|
||||
.btn-dismiss-tags:disabled {
|
||||
opacity: var(--fs-disabled-opacity);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@
|
||||
}
|
||||
|
||||
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: var(--color-text-muted, var(--color-text-secondary));
|
||||
color: var(--color-text-muted);
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
@@ -234,5 +234,5 @@
|
||||
}
|
||||
|
||||
.tiptap-wrapper:focus-within {
|
||||
box-shadow: var(--focus-ring, 0 0 0 2px var(--color-primary));
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
+331
-161
@@ -1,149 +1,319 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Inter:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:ital,wght@0,400;1,400&display=swap');
|
||||
|
||||
/* ==========================================================================
|
||||
GENERATED FROM THE DESIGN SYSTEM — Scribe (design system 2), which inherits
|
||||
the FabledSword house style (design system 1).
|
||||
|
||||
Do not hand-edit the --fs-* block below. Edit the design system in the app
|
||||
and regenerate: /design-systems -> Master stylesheet -> Copy.
|
||||
|
||||
DARK IS THE BASE LAYER. The kit is dark-mode-first, so :root carries the dark
|
||||
palette and [data-theme="light"] overrides it. That is the inverse of how this
|
||||
file used to read, and it is deliberate: the light palette was never specified
|
||||
by any rule, so it is recorded as a departure rather than as the default.
|
||||
|
||||
Only 12 tokens differ between modes. Everything else — spacing, type, motion,
|
||||
radius, and every derived colour — is stated once, because a value built with
|
||||
var() resolves where it is USED, not where it is written.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* Light mode — warm parchment palette */
|
||||
--color-bg: #F5F1E8;
|
||||
--color-bg-secondary: #FBF8F0;
|
||||
--color-bg-card: #FBF8F0;
|
||||
--color-surface: #EFEAE0;
|
||||
--color-text: #14171A;
|
||||
--color-text-secondary: #5A5852;
|
||||
--color-text-muted: #9A9890;
|
||||
--color-border: #D9D6CE;
|
||||
--color-input-border: #D9D6CE;
|
||||
--color-primary: #5B4A8A;
|
||||
--color-danger: #C04A1F;
|
||||
--color-tag-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-tag-text: #5B4A8A;
|
||||
--color-shadow: rgba(0, 0, 0, 0.08);
|
||||
--color-toast-success: #4A5D3F;
|
||||
--color-toast-error: #C04A1F;
|
||||
--color-status-todo: #3F4651;
|
||||
--color-status-todo-bg: rgba(63, 70, 81, 0.10);
|
||||
--color-status-in-progress: #5B4A8A;
|
||||
--color-status-in-progress-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-status-done: #4A5D3F;
|
||||
--color-status-done-bg: rgba(74, 93, 63, 0.12);
|
||||
--color-priority-low: #3D5A6E;
|
||||
--color-priority-low-bg: rgba(61, 90, 110, 0.12);
|
||||
--color-priority-medium: #8B6F1E;
|
||||
--color-priority-medium-bg: rgba(139, 111, 30, 0.12);
|
||||
--color-priority-high: #C04A1F;
|
||||
--color-priority-high-bg: rgba(192, 74, 31, 0.12);
|
||||
--color-wikilink: #5B4A8A;
|
||||
--color-wikilink-bg: rgba(91, 74, 138, 0.12);
|
||||
--color-overdue: #C04A1F;
|
||||
--color-code-bg: #EBEDF0;
|
||||
--color-code-inline-bg: #EBEDF0;
|
||||
--color-table-stripe: rgba(20, 23, 26, 0.025);
|
||||
--color-success: #4A5D3F;
|
||||
--color-warning: #8B6F1E;
|
||||
--color-input-bar-bg: #EFEAE0;
|
||||
--color-input-bar-text: #14171A;
|
||||
--color-input-bar-placeholder: rgba(20, 23, 26, 0.4);
|
||||
--color-overlay: rgba(0, 0, 0, 0.45);
|
||||
--color-bubble-user-bg: transparent;
|
||||
--color-bubble-user-border: #D9D6CE;
|
||||
--color-bubble-user-text: #5A5852;
|
||||
--color-bubble-asst-shadow: 0 2px 14px rgba(91, 74, 138, 0.06), 0 1px 4px rgba(0, 0, 0, 0.05);
|
||||
--color-primary-solid: #5B4A8A;
|
||||
--color-primary-deep: #3F3560;
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 10px rgba(91, 74, 138, 0.35);
|
||||
--glow-cta-hover: 0 4px 20px rgba(91, 74, 138, 0.55);
|
||||
--glow-soft: 0 0 16px rgba(91, 74, 138, 0.35);
|
||||
--color-primary-faint: rgba(91, 74, 138, 0.08);
|
||||
--color-primary-tint: rgba(91, 74, 138, 0.12);
|
||||
--color-primary-wash: rgba(91, 74, 138, 0.20);
|
||||
/* accent */
|
||||
--fs-accent: #5B4A8A; /* Scribe's signature colour */
|
||||
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent); /* Pill, tag and badge backgrounds */
|
||||
--fs-accent-faint: color-mix(in srgb, var(--fs-accent) 8%, transparent); /* The faintest accent wash */
|
||||
--fs-accent-deep: color-mix(in srgb, var(--fs-accent) 70%, black); /* The accent, darkened */
|
||||
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 22%, transparent); /* Heaviest accent tint */
|
||||
--fs-gradient-cta: linear-gradient(135deg, var(--fs-accent), var(--fs-accent-deep));
|
||||
--fs-glow-cta: 0 2px 10px color-mix(in srgb, var(--fs-accent) 35%, transparent);
|
||||
--fs-glow-cta-hover: 0 4px 24px color-mix(in srgb, var(--fs-accent) 65%, transparent);
|
||||
|
||||
/* Action color set — Hybrid rule: action buttons use these, accent reserved for brand moments */
|
||||
--color-action-primary: #4A5D3F;
|
||||
--color-action-primary-hover: #5A6F4D;
|
||||
--color-action-secondary: #8B7355;
|
||||
--color-action-secondary-hover: #A0876A;
|
||||
--color-action-destructive: #6B2118;
|
||||
--color-action-destructive-hover: #7E2A1F;
|
||||
--color-action-ghost-border: #3F4651;
|
||||
/* action */
|
||||
--fs-action-primary: #4A5D3F; /* Save, Submit, Confirm */
|
||||
--fs-action-secondary: #8B7355; /* Non-destructive alternates */
|
||||
--fs-action-tertiary: var(--fs-border-color); /* Ghost / outline actions */
|
||||
--fs-action-destructive: var(--fs-destructive);
|
||||
--fs-action-primary-hover: color-mix(in srgb, var(--fs-action-primary) 88%, white);
|
||||
--fs-action-secondary-hover: color-mix(in srgb, var(--fs-action-secondary) 88%, white);
|
||||
--fs-action-destructive-hover: color-mix(in srgb, var(--fs-action-destructive) 88%, white);
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 18px;
|
||||
--radius-pill: 9999px;
|
||||
--focus-ring: 0 0 0 2px rgba(91, 74, 138, 0.5);
|
||||
/* Layout */
|
||||
--page-max-width: 1200px;
|
||||
--page-padding-x: 1rem;
|
||||
--sidebar-width: 260px;
|
||||
--chat-reading-width: min(1200px, 100%);
|
||||
--chat-context-sidebar-width: 220px;
|
||||
/* border */
|
||||
--fs-border-color: #3F4651; /* Borders, dividers and ghost outlines */
|
||||
--fs-border: 0.5px solid var(--fs-border-color);
|
||||
--fs-border-hover: 0.5px solid color-mix(in srgb, var(--fs-text-secondary) 30%, transparent);
|
||||
--fs-border-active: 2px solid var(--fs-accent); /* Selected card or active tab only */
|
||||
|
||||
/* editor */
|
||||
--fs-wikilink: var(--fs-accent);
|
||||
|
||||
/* elevation */
|
||||
--fs-shadow-1: 0 1px 0 rgba(0,0,0,0.4); /* Hairline lift */
|
||||
--fs-shadow-2: 0 4px 12px rgba(0,0,0,0.35); /* Dropdowns, popovers */
|
||||
--fs-shadow-3: 0 16px 40px rgba(0,0,0,0.5); /* Modals */
|
||||
|
||||
/* focus */
|
||||
--fs-focus-ring: 0 0 0 2px var(--fs-accent);
|
||||
|
||||
/* font */
|
||||
--fs-font-display: 'Fraunces', Georgia, serif;
|
||||
--fs-font-body: 'Inter', system-ui, sans-serif;
|
||||
--fs-font-mono: 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
|
||||
|
||||
/* icon */
|
||||
--fs-icon-stroke: 1.5px; /* at 24px */
|
||||
--fs-icon-stroke-sm: 1px; /* at 16px */
|
||||
|
||||
/* layout */
|
||||
--fs-layout-page-max: 1200px;
|
||||
--fs-layout-page-pad: 1rem;
|
||||
--fs-layout-sidebar: 260px;
|
||||
--fs-layout-header: 52px;
|
||||
|
||||
/* motion */
|
||||
--fs-ease: cubic-bezier(0.2, 0.6, 0.2, 1); /* the one curve */
|
||||
--fs-dur-fast: 120ms;
|
||||
--fs-dur-base: 180ms;
|
||||
--fs-dur-slow: 280ms;
|
||||
|
||||
/* priority */
|
||||
--fs-priority-low: var(--fs-info);
|
||||
--fs-priority-low-bg: color-mix(in srgb, var(--fs-priority-low) 12%, transparent);
|
||||
--fs-priority-medium: var(--fs-warning);
|
||||
--fs-priority-medium-bg: color-mix(in srgb, var(--fs-priority-medium) 12%, transparent);
|
||||
--fs-priority-high: var(--fs-error);
|
||||
--fs-priority-high-bg: color-mix(in srgb, var(--fs-priority-high) 12%, transparent);
|
||||
|
||||
/* radius */
|
||||
--fs-radius-sm: 4px; /* pills, tags, code spans */
|
||||
--fs-radius-md: 8px; /* buttons, inputs, small cards */
|
||||
--fs-radius-lg: 12px; /* cards, panels, modals */
|
||||
--fs-radius-xl: 16px; /* hero containers */
|
||||
--fs-radius-pill: 9999px;
|
||||
|
||||
/* semantic */
|
||||
--fs-success: var(--fs-action-primary);
|
||||
--fs-warning: #8B6F1E;
|
||||
--fs-error: #C04A1F;
|
||||
--fs-info: #3D5A6E;
|
||||
--fs-destructive: #6B2118; /* irreversible — deliberately not the error colour */
|
||||
|
||||
/* space */
|
||||
--fs-space-1: 4px;
|
||||
--fs-space-2: 8px;
|
||||
--fs-space-3: 12px;
|
||||
--fs-space-4: 16px;
|
||||
--fs-space-5: 20px;
|
||||
--fs-space-6: 24px;
|
||||
--fs-space-7: 32px;
|
||||
--fs-space-8: 48px;
|
||||
--fs-space-9: 64px;
|
||||
--fs-space-10: 96px;
|
||||
|
||||
/* state */
|
||||
--fs-disabled-opacity: 0.5;
|
||||
--fs-overlay: rgba(0, 0, 0, 0.65);
|
||||
|
||||
/* status */
|
||||
--fs-status-todo: var(--fs-border-color);
|
||||
--fs-status-todo-bg: color-mix(in srgb, var(--fs-status-todo) 12%, transparent);
|
||||
--fs-status-in-progress: var(--fs-accent);
|
||||
--fs-status-in-progress-bg: color-mix(in srgb, var(--fs-status-in-progress) 12%, transparent);
|
||||
--fs-status-done: var(--fs-success);
|
||||
--fs-status-done-bg: color-mix(in srgb, var(--fs-status-done) 12%, transparent);
|
||||
--fs-overdue: var(--fs-error);
|
||||
--fs-status-cancelled: var(--fs-text-tertiary); /* set aside, not failed */
|
||||
|
||||
/* surface */
|
||||
--fs-surface-page: #14171A; /* page bg, deepest surface */
|
||||
--fs-surface-raised: #1E2228; /* cards, raised elements */
|
||||
--fs-surface-hover: #2C313A; /* hovered surfaces */
|
||||
--fs-surface-code: var(--fs-surface-page);
|
||||
--fs-surface-code-inline: var(--fs-surface-raised);
|
||||
--fs-table-stripe: color-mix(in srgb, var(--fs-text-primary) 3%, transparent);
|
||||
|
||||
/* text */
|
||||
--fs-text-primary: #E8E4D8; /* body, headings, labels — inverts by mode */
|
||||
--fs-text-secondary: #C2BFB4;
|
||||
--fs-text-tertiary: #9C9A92;
|
||||
--fs-text-on-action: #E8E4D8; /* text on a filled colour — NOT mode-dependent */
|
||||
|
||||
/* type */
|
||||
--fs-size-display: 40px;
|
||||
--fs-size-h1: 32px;
|
||||
--fs-size-h2: 24px;
|
||||
--fs-size-h3: 18px;
|
||||
--fs-size-body: 15px;
|
||||
--fs-size-body-sm: 13px;
|
||||
--fs-size-label: 12px;
|
||||
--fs-size-code: 13px;
|
||||
--fs-size-tiny: 11px; /* the only ALL CAPS, the only non-default tracking */
|
||||
--fs-weight-regular: 400;
|
||||
--fs-weight-medium: 500; /* the heaviest the system goes */
|
||||
--fs-leading-heading: 1.3;
|
||||
--fs-leading-body: 1.5;
|
||||
--fs-leading-longform: 1.7;
|
||||
--fs-tracking-tiny: 0.08em;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
/* Dark mode — Obsidian / Iron / Pewter */
|
||||
--color-bg: #14171A;
|
||||
--color-bg-secondary: #1E2228;
|
||||
--color-bg-card: #1E2228;
|
||||
--color-surface: #2C313A;
|
||||
--color-text: #E8E4D8;
|
||||
--color-text-secondary: #C2BFB4;
|
||||
--color-text-muted: #9C9A92;
|
||||
--color-border: #3F4651;
|
||||
--color-input-border: #3F4651;
|
||||
--color-primary: #5B4A8A;
|
||||
--color-danger: #C04A1F;
|
||||
--color-tag-bg: rgba(91, 74, 138, 0.15);
|
||||
--color-tag-text: #5B4A8A;
|
||||
[data-theme="light"] {
|
||||
/* accent */
|
||||
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 20%, transparent);
|
||||
--fs-glow-cta-hover: 0 4px 20px color-mix(in srgb, var(--fs-accent) 55%, transparent);
|
||||
|
||||
/* border */
|
||||
--fs-border-color: #D9D6CE;
|
||||
|
||||
/* state */
|
||||
--fs-overlay: rgba(0, 0, 0, 0.45);
|
||||
|
||||
/* surface */
|
||||
--fs-surface-page: #F5F1E8;
|
||||
--fs-surface-raised: #FBF8F0;
|
||||
--fs-surface-hover: #EFEAE0;
|
||||
--fs-surface-code: #EBEDF0;
|
||||
--fs-surface-code-inline: #EBEDF0;
|
||||
|
||||
/* text */
|
||||
--fs-text-primary: #14171A;
|
||||
--fs-text-secondary: #5A5852;
|
||||
--fs-text-tertiary: #9A9890;
|
||||
}
|
||||
|
||||
/* SUPERSEDES — write the token, not the literal.
|
||||
* #fff -> --fs-text-on-action
|
||||
* #ffffff -> --fs-text-on-action
|
||||
* white -> --fs-text-on-action
|
||||
* bold -> --fs-weight-medium
|
||||
* bolder -> --fs-weight-medium
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
COMPATIBILITY ALIASES — the app's historical names, pointing at the system.
|
||||
|
||||
These exist so ~55 components keep working while they migrate to --fs-*
|
||||
one at a time. Every one is a plain var() reference, which is what lets this
|
||||
block be declared ONCE: when [data-theme="light"] moves --fs-surface-page,
|
||||
--color-bg follows, because the alias resolves at use time.
|
||||
|
||||
That is why this file lost 48 of its 60 dark-mode overrides — they were all
|
||||
restating relationships the aliases now express directly.
|
||||
|
||||
Removing this block is a rename sweep across the components, tracked
|
||||
separately. Nothing new should reference a --color-* name.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* surfaces */
|
||||
--color-bg: var(--fs-surface-page);
|
||||
--color-bg-secondary: var(--fs-surface-raised);
|
||||
--color-bg-card: var(--fs-surface-raised);
|
||||
--color-surface: var(--fs-surface-hover);
|
||||
--color-code-bg: var(--fs-surface-code);
|
||||
--color-code-inline-bg: var(--fs-surface-code-inline);
|
||||
--color-table-stripe: var(--fs-table-stripe);
|
||||
--color-overlay: var(--fs-overlay);
|
||||
|
||||
/* text */
|
||||
--color-text: var(--fs-text-primary);
|
||||
--color-text-secondary: var(--fs-text-secondary);
|
||||
--color-text-muted: var(--fs-text-tertiary);
|
||||
|
||||
/* lines */
|
||||
--color-border: var(--fs-border-color);
|
||||
--color-input-border: var(--fs-border-color);
|
||||
--focus-ring: var(--fs-focus-ring);
|
||||
|
||||
/* brand */
|
||||
--color-primary: var(--fs-accent);
|
||||
--color-primary-solid: var(--fs-accent);
|
||||
--color-primary-deep: var(--fs-accent-deep);
|
||||
--color-primary-faint: var(--fs-accent-faint);
|
||||
--color-primary-tint: var(--fs-accent-soft);
|
||||
--color-primary-wash: var(--fs-accent-wash);
|
||||
--color-tag-bg: var(--fs-accent-soft);
|
||||
--color-tag-text: var(--fs-accent);
|
||||
--color-wikilink: var(--fs-wikilink);
|
||||
--color-wikilink-bg: var(--fs-accent-soft);
|
||||
--gradient-cta: var(--fs-gradient-cta);
|
||||
--glow-cta: var(--fs-glow-cta);
|
||||
--glow-cta-hover: var(--fs-glow-cta-hover);
|
||||
|
||||
/* actions */
|
||||
--color-action-primary: var(--fs-action-primary);
|
||||
--color-action-primary-hover: var(--fs-action-primary-hover);
|
||||
--color-action-secondary: var(--fs-action-secondary);
|
||||
--color-action-secondary-hover: var(--fs-action-secondary-hover);
|
||||
--color-action-destructive: var(--fs-action-destructive);
|
||||
--color-action-destructive-hover: var(--fs-action-destructive-hover);
|
||||
|
||||
/* semantic */
|
||||
--color-success: var(--fs-success);
|
||||
--color-warning: var(--fs-warning);
|
||||
--color-danger: var(--fs-error);
|
||||
--color-overdue: var(--fs-overdue);
|
||||
--color-toast-success: var(--fs-success);
|
||||
--color-toast-error: var(--fs-error);
|
||||
--color-shadow: rgba(0, 0, 0, 0.4);
|
||||
--color-toast-success: #4A5D3F;
|
||||
--color-toast-error: #C04A1F;
|
||||
--color-status-todo: #3F4651;
|
||||
--color-status-todo-bg: rgba(63, 70, 81, 0.18);
|
||||
--color-status-in-progress: #5B4A8A;
|
||||
--color-status-in-progress-bg: rgba(91, 74, 138, 0.18);
|
||||
--color-status-done: #4A5D3F;
|
||||
--color-status-done-bg: rgba(74, 93, 63, 0.18);
|
||||
--color-priority-low: #3D5A6E;
|
||||
--color-priority-low-bg: rgba(61, 90, 110, 0.18);
|
||||
--color-priority-medium: #8B6F1E;
|
||||
--color-priority-medium-bg: rgba(139, 111, 30, 0.18);
|
||||
--color-priority-high: #C04A1F;
|
||||
--color-priority-high-bg: rgba(192, 74, 31, 0.18);
|
||||
--color-wikilink: #5B4A8A;
|
||||
--color-wikilink-bg: rgba(91, 74, 138, 0.18);
|
||||
--color-overdue: #C04A1F;
|
||||
--color-code-bg: #14171A;
|
||||
--color-code-inline-bg: #1E2228;
|
||||
--color-table-stripe: rgba(255, 255, 255, 0.025);
|
||||
--color-success: #4A5D3F;
|
||||
--color-warning: #8B6F1E;
|
||||
--color-input-bar-bg: #1E2228;
|
||||
--color-input-bar-text: #E8E4D8;
|
||||
--color-input-bar-placeholder: rgba(232, 228, 216, 0.35);
|
||||
--color-overlay: rgba(0, 0, 0, 0.65);
|
||||
--color-bubble-user-bg: transparent;
|
||||
--color-bubble-user-border: #3F4651;
|
||||
--color-bubble-user-text: #C2BFB4;
|
||||
--color-bubble-asst-shadow: 0 4px 28px rgba(91, 74, 138, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--color-primary-solid: #5B4A8A;
|
||||
--color-primary-deep: #3F3560;
|
||||
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
|
||||
--glow-cta: 0 2px 12px rgba(91, 74, 138, 0.45);
|
||||
--glow-cta-hover: 0 4px 24px rgba(91, 74, 138, 0.65);
|
||||
--glow-soft: 0 0 18px rgba(91, 74, 138, 0.4);
|
||||
--color-primary-faint: rgba(91, 74, 138, 0.10);
|
||||
--color-primary-tint: rgba(91, 74, 138, 0.14);
|
||||
--color-primary-wash: rgba(91, 74, 138, 0.22);
|
||||
|
||||
/* Action color set — identical across themes */
|
||||
--color-action-primary: #4A5D3F;
|
||||
--color-action-primary-hover: #5A6F4D;
|
||||
--color-action-secondary: #8B7355;
|
||||
--color-action-secondary-hover: #A0876A;
|
||||
--color-action-destructive: #6B2118;
|
||||
--color-action-destructive-hover: #7E2A1F;
|
||||
--color-action-ghost-border: #3F4651;
|
||||
/* task status + priority */
|
||||
--color-status-todo: var(--fs-status-todo);
|
||||
--color-status-todo-bg: var(--fs-status-todo-bg);
|
||||
--color-status-in-progress: var(--fs-status-in-progress);
|
||||
--color-status-in-progress-bg: var(--fs-status-in-progress-bg);
|
||||
--color-status-done: var(--fs-status-done);
|
||||
--color-status-done-bg: var(--fs-status-done-bg);
|
||||
--color-priority-low: var(--fs-priority-low);
|
||||
--color-priority-low-bg: var(--fs-priority-low-bg);
|
||||
--color-priority-medium: var(--fs-priority-medium);
|
||||
--color-priority-medium-bg: var(--fs-priority-medium-bg);
|
||||
--color-priority-high: var(--fs-priority-high);
|
||||
--color-priority-high-bg: var(--fs-priority-high-bg);
|
||||
|
||||
/* geometry */
|
||||
--radius-sm: var(--fs-radius-sm);
|
||||
--radius-md: var(--fs-radius-lg); /* NB: the app's "md" is the system's LARGE */
|
||||
--radius-lg: var(--fs-radius-xl); /* and the app's "lg" is the system's XL */
|
||||
--page-max-width: var(--fs-layout-page-max);
|
||||
--page-padding-x: var(--fs-layout-page-pad);
|
||||
--sidebar-width: var(--fs-layout-sidebar);
|
||||
--header-height: var(--fs-layout-header);
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Names components reference that were NEVER declared anywhere.
|
||||
|
||||
Each of these was reached for with a hardcoded fallback, so the page
|
||||
rendered — but the fallback was what rendered, always, and several were
|
||||
off-palette: --color-primary-bg fell back to an indigo, --color-destructive
|
||||
to a brick that is not the oxblood, --color-status-cancelled to a grey from
|
||||
no palette in this system.
|
||||
|
||||
Wiring them to real tokens is the whole point of the exercise. Expect small
|
||||
visual shifts exactly where a fallback had drifted; that shift IS the fix.
|
||||
------------------------------------------------------------------ */
|
||||
--color-accent: var(--fs-accent);
|
||||
/* Foreground ON the accent, so it follows the accent's mode-independence,
|
||||
not the page text's. Pointing this at --fs-text-primary made it invert to
|
||||
obsidian on light — over a mid-tone accent, well under the AA floor. */
|
||||
--color-accent-fg: var(--fs-text-on-action);
|
||||
--color-hover: var(--fs-surface-hover);
|
||||
--color-bg-hover: var(--fs-surface-hover);
|
||||
--color-bg-tertiary: var(--fs-surface-hover);
|
||||
--color-surface-2: var(--fs-surface-hover);
|
||||
--color-surface-alt: var(--fs-surface-hover);
|
||||
--color-surface-raised: var(--fs-surface-raised);
|
||||
--color-input-bg: var(--fs-surface-page);
|
||||
--color-muted: var(--fs-text-tertiary);
|
||||
--color-destructive: var(--fs-destructive);
|
||||
--color-primary-bg: var(--fs-accent-soft);
|
||||
--color-status-cancelled: var(--fs-status-cancelled);
|
||||
--font-display: var(--fs-font-display);
|
||||
--font-mono: var(--fs-font-mono);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Base element styles
|
||||
========================================================================== */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
@@ -152,36 +322,36 @@
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", Roboto, sans-serif;
|
||||
background: var(--fs-surface-page);
|
||||
color: var(--fs-text-primary);
|
||||
font-family: var(--fs-font-body);
|
||||
font-feature-settings: "cv11";
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
line-height: var(--fs-leading-body);
|
||||
transition: background-color var(--fs-dur-base) var(--fs-ease),
|
||||
color var(--fs-dur-base) var(--fs-ease);
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-family: var(--fs-font-display);
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
font-weight: var(--fs-weight-medium);
|
||||
line-height: var(--fs-leading-heading);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
font-family: var(--fs-font-body);
|
||||
font-weight: var(--fs-weight-medium);
|
||||
line-height: var(--fs-leading-heading);
|
||||
}
|
||||
|
||||
code, pre, kbd, samp {
|
||||
font-family: 'JetBrains Mono', ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-family: var(--fs-font-mono);
|
||||
font-feature-settings: "liga", "calt";
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(91, 74, 138, 0.3);
|
||||
color: var(--color-text);
|
||||
background: var(--fs-accent-wash);
|
||||
color: var(--fs-text-primary);
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
@@ -190,15 +360,15 @@ select:focus-visible,
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--fs-focus-ring);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
}
|
||||
|
||||
button:not(:disabled):active,
|
||||
.btn:not(:disabled):active,
|
||||
[role="button"]:not(:disabled):active {
|
||||
transform: scale(0.97);
|
||||
transition: transform 0.08s ease;
|
||||
transition: transform 0.08s var(--fs-ease);
|
||||
}
|
||||
|
||||
/* Responsive breakpoints: 480px (phone), 768px (tablet), 1024px (desktop) */
|
||||
@@ -228,11 +398,11 @@ button:not(:disabled):active,
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 9999px;
|
||||
background: var(--fs-border-color);
|
||||
border-radius: var(--fs-radius-pill);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-text-muted);
|
||||
background: var(--fs-text-tertiary);
|
||||
}
|
||||
|
||||
/* Floating inline assist button (teleported to body, cannot be scoped) */
|
||||
@@ -240,14 +410,14 @@ button:not(:disabled):active,
|
||||
position: fixed;
|
||||
z-index: 150;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
background: var(--fs-accent);
|
||||
color: var(--fs-text-primary);
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
border-radius: var(--fs-radius-pill);
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
font-size: var(--fs-size-body-sm);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
box-shadow: var(--fs-shadow-2);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.inline-assist-btn:hover {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useShortcuts } from "@/composables/useShortcuts";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import AppLogo from "@/components/AppLogo.vue";
|
||||
import NotificationBell from "@/components/NotificationBell.vue";
|
||||
import { Sun, Moon, Settings, Trash2 } from "lucide-vue-next";
|
||||
import { Sun, Moon, Palette, Settings, Trash2 } from "lucide-vue-next";
|
||||
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { toggleShortcuts } = useShortcuts();
|
||||
@@ -47,8 +47,8 @@ router.afterEach(() => {
|
||||
<div class="nav-pill-bar">
|
||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,6 +64,16 @@ router.afterEach(() => {
|
||||
<Moon v-else :size="16" />
|
||||
</button>
|
||||
|
||||
<!-- Design. An icon rather than a sixth primary nav link: it's a
|
||||
meta-surface like Trash and Settings, but hiding it entirely would
|
||||
defeat the point of having somewhere the design system is visible.
|
||||
Points at the RECORD, not the live-token view — the record is what
|
||||
you work with; the live view is the check on it, and it's a tab
|
||||
away. -->
|
||||
<router-link to="/design-systems" class="btn-icon" aria-label="Design" title="Design">
|
||||
<Palette :size="16" />
|
||||
</router-link>
|
||||
|
||||
<!-- Trash link -->
|
||||
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
|
||||
<Trash2 :size="16" />
|
||||
@@ -93,11 +103,12 @@ router.afterEach(() => {
|
||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
|
||||
<router-link to="/calendar" class="nav-link">Calendar</router-link>
|
||||
<router-link to="/projects" class="nav-link">Projects</router-link>
|
||||
<router-link to="/snippets" class="nav-link">Snippets</router-link>
|
||||
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
|
||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
<router-link to="/design-systems" class="nav-link">Design</router-link>
|
||||
<router-link to="/trash" class="nav-link">Trash</router-link>
|
||||
<router-link to="/settings" class="nav-link">Settings</router-link>
|
||||
<div class="mobile-divider"></div>
|
||||
@@ -118,7 +129,7 @@ router.afterEach(() => {
|
||||
<style scoped>
|
||||
.app-header {
|
||||
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
|
||||
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
position: relative;
|
||||
}
|
||||
.nav {
|
||||
@@ -186,8 +197,8 @@ router.afterEach(() => {
|
||||
.nav-link.router-link-active {
|
||||
color: var(--color-primary-solid);
|
||||
font-weight: 500;
|
||||
background: rgba(91, 74, 138, 0.25);
|
||||
box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
|
||||
background: color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Sub-navigation for the Design surface.
|
||||
*
|
||||
* There are two pages here and they are halves of ONE thing: the record that
|
||||
* decides the styling, and what the browser is actually rendering from it. They
|
||||
* were briefly two top-level nav entries, which put the read-only diagnostic
|
||||
* first and buried the editable record under it — backwards, since the record
|
||||
* is the thing you work with and the live view is the check on it.
|
||||
*
|
||||
* A component rather than the same markup pasted into both views: two copies of
|
||||
* a tab bar diverge the moment a third tab appears, and that is the exact shape
|
||||
* of duplication this whole surface exists to make visible.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="design-tabs" aria-label="Design views">
|
||||
<router-link to="/design-systems" class="design-tab">Design system</router-link>
|
||||
<router-link to="/design" class="design-tab">Live tokens</router-link>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.design-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.design-tab {
|
||||
padding: 0.5rem 0.9rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.design-tab:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* `router-link-active` rather than `-exact-active`: both routes are leaves, and
|
||||
exact matching would drop the highlight on any future child route. */
|
||||
.design-tab.router-link-active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -110,8 +110,8 @@ function markerFor(type: DiffLine['type']): string {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.diff-summary-ins { color: var(--color-success, #2ecc71); }
|
||||
.diff-summary-del { color: var(--color-danger, #e74c3c); }
|
||||
.diff-summary-ins { color: var(--color-success); }
|
||||
.diff-summary-del { color: var(--color-danger); }
|
||||
|
||||
.diff-scroll {
|
||||
flex: 1;
|
||||
@@ -136,13 +136,13 @@ function markerFor(type: DiffLine['type']): string {
|
||||
}
|
||||
|
||||
.diff-delete {
|
||||
background: color-mix(in srgb, var(--color-danger, #e74c3c) 12%, transparent);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.diff-insert {
|
||||
background: color-mix(in srgb, var(--color-success, #2ecc71) 12%, transparent);
|
||||
color: var(--color-success, #2ecc71);
|
||||
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.diff-equal {
|
||||
|
||||
@@ -1,677 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const props = defineProps<{
|
||||
// null = create mode; EventEntry = edit mode
|
||||
event: EventEntry | null;
|
||||
// pre-filled date string for create mode (YYYY-MM-DD or ISO)
|
||||
initialDate?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void;
|
||||
(e: "created", event: EventEntry): void;
|
||||
(e: "updated", event: EventEntry): void;
|
||||
(e: "deleted", id: number): void;
|
||||
}>();
|
||||
|
||||
const toast = useToastStore();
|
||||
|
||||
const isEditMode = computed(() => !!props.event);
|
||||
const saving = ref(false);
|
||||
const deleting = ref(false);
|
||||
const deleteConfirm = ref(false);
|
||||
|
||||
// Form fields
|
||||
const title = ref("");
|
||||
const startDate = ref("");
|
||||
const startTime = ref("");
|
||||
const endDate = ref("");
|
||||
const endTime = ref("");
|
||||
const allDay = ref(false);
|
||||
const description = ref("");
|
||||
const location = ref("");
|
||||
const color = ref("");
|
||||
const projectId = ref<number | null>(null);
|
||||
const recurrence = ref<string>("");
|
||||
|
||||
// Preset RRULE strings. The select binds to `recurrencePreset`, which writes
|
||||
// through to `recurrence`. CalDAV-imported rules with extra parts
|
||||
// (e.g. `FREQ=WEEKLY;BYDAY=MO,WE,FR`) fall through to "custom" and the raw
|
||||
// string is shown read-only below the select.
|
||||
const RECURRENCE_PRESETS: Record<string, string> = {
|
||||
none: "",
|
||||
daily: "FREQ=DAILY",
|
||||
weekly: "FREQ=WEEKLY",
|
||||
monthly: "FREQ=MONTHLY",
|
||||
yearly: "FREQ=YEARLY",
|
||||
};
|
||||
|
||||
const recurrencePreset = computed<string>({
|
||||
get() {
|
||||
const r = (recurrence.value || "").trim();
|
||||
if (!r) return "none";
|
||||
for (const [key, val] of Object.entries(RECURRENCE_PRESETS)) {
|
||||
if (val && val === r) return key;
|
||||
}
|
||||
return "custom";
|
||||
},
|
||||
set(key: string) {
|
||||
if (key === "custom") return; // no-op; can't pick custom from dropdown
|
||||
recurrence.value = RECURRENCE_PRESETS[key] ?? "";
|
||||
},
|
||||
});
|
||||
|
||||
const isCustomRecurrence = computed(() => recurrencePreset.value === "custom");
|
||||
|
||||
function dateFromIso(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso.slice(0, 10);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function timeFromIso(iso: string): string {
|
||||
if (!iso.includes("T")) return "09:00";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso.slice(11, 16);
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function toIso(date: string, time: string): string {
|
||||
if (!time) return `${date}T00:00:00`;
|
||||
// Include local timezone offset so the server stores the correct UTC time
|
||||
const local = new Date(`${date}T${time}:00`);
|
||||
const off = -local.getTimezoneOffset();
|
||||
const sign = off >= 0 ? "+" : "-";
|
||||
const h = String(Math.floor(Math.abs(off) / 60)).padStart(2, "0");
|
||||
const min = String(Math.abs(off) % 60).padStart(2, "0");
|
||||
return `${date}T${time}:00${sign}${h}:${min}`;
|
||||
}
|
||||
|
||||
// ── Time helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Round up to next 30-minute boundary */
|
||||
function nextRoundedTime(): string {
|
||||
const now = new Date();
|
||||
let h = now.getHours();
|
||||
let m = now.getMinutes();
|
||||
if (m <= 30) { m = 30; }
|
||||
else { m = 0; h = (h + 1) % 24; }
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Add hours to a time string (HH:MM), returns HH:MM */
|
||||
function addHours(time: string, hours: number): string {
|
||||
const [h, m] = time.split(":").map(Number);
|
||||
const totalMin = h * 60 + m + hours * 60;
|
||||
const nh = Math.floor(totalMin / 60) % 24;
|
||||
const nm = totalMin % 60;
|
||||
return `${String(nh).padStart(2, "0")}:${String(nm).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Duration in minutes between two time strings */
|
||||
function durationMin(start: string, end: string): number {
|
||||
const [sh, sm] = start.split(":").map(Number);
|
||||
const [eh, em] = end.split(":").map(Number);
|
||||
return (eh * 60 + em) - (sh * 60 + sm);
|
||||
}
|
||||
|
||||
/** True if a date+time is in the past */
|
||||
const isPastEvent = computed(() => {
|
||||
if (allDay.value || !startDate.value || !startTime.value) return false;
|
||||
const dt = new Date(`${startDate.value}T${startTime.value}:00`);
|
||||
return dt.getTime() < Date.now();
|
||||
});
|
||||
|
||||
// Track duration so end moves with start
|
||||
let _lastDurationMin = 60;
|
||||
|
||||
function resetForm() {
|
||||
if (props.event) {
|
||||
title.value = props.event.title;
|
||||
allDay.value = props.event.all_day;
|
||||
startDate.value = props.event.all_day ? props.event.start_dt.slice(0, 10) : dateFromIso(props.event.start_dt);
|
||||
startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt);
|
||||
endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : startDate.value;
|
||||
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : addHours(startTime.value || "09:00", 1);
|
||||
description.value = props.event.description || "";
|
||||
location.value = props.event.location || "";
|
||||
color.value = props.event.color || "";
|
||||
projectId.value = props.event.project_id;
|
||||
recurrence.value = props.event.recurrence || "";
|
||||
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
|
||||
if (_lastDurationMin <= 0) _lastDurationMin = 60;
|
||||
} else {
|
||||
title.value = "";
|
||||
allDay.value = false;
|
||||
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
|
||||
const roundedStart = nextRoundedTime();
|
||||
startDate.value = base;
|
||||
startTime.value = roundedStart;
|
||||
endDate.value = base;
|
||||
endTime.value = addHours(roundedStart, 1);
|
||||
description.value = "";
|
||||
location.value = "";
|
||||
color.value = "";
|
||||
projectId.value = null;
|
||||
recurrence.value = "";
|
||||
_lastDurationMin = 60;
|
||||
}
|
||||
deleteConfirm.value = false;
|
||||
}
|
||||
|
||||
// When start time changes, move end time to preserve duration
|
||||
watch(startTime, (newStart) => {
|
||||
if (allDay.value || !newStart) return;
|
||||
endTime.value = addHours(newStart, _lastDurationMin / 60);
|
||||
});
|
||||
|
||||
// When start date changes, move end date to match (preserve same-day or multi-day gap)
|
||||
watch(startDate, (newDate) => {
|
||||
if (!newDate) return;
|
||||
endDate.value = newDate;
|
||||
});
|
||||
|
||||
// When end time changes manually, update the tracked duration (but guard against end < start)
|
||||
watch(endTime, (newEnd) => {
|
||||
if (allDay.value || !newEnd || !startTime.value) return;
|
||||
const dur = durationMin(startTime.value, newEnd);
|
||||
if (dur <= 0) {
|
||||
// Snap back to start + 1 hour
|
||||
endTime.value = addHours(startTime.value, 1);
|
||||
_lastDurationMin = 60;
|
||||
} else {
|
||||
_lastDurationMin = dur;
|
||||
}
|
||||
});
|
||||
|
||||
// All-day toggle: clear/restore times
|
||||
watch(allDay, (isAllDay) => {
|
||||
if (isAllDay) {
|
||||
startTime.value = "";
|
||||
endTime.value = "";
|
||||
} else {
|
||||
const rounded = nextRoundedTime();
|
||||
startTime.value = rounded;
|
||||
endTime.value = addHours(rounded, 1);
|
||||
_lastDurationMin = 60;
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => props.event, resetForm, { immediate: true });
|
||||
watch(() => props.initialDate, resetForm);
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
if (deleteConfirm.value) {
|
||||
// Esc cancels the delete-confirm rather than closing the modal —
|
||||
// gives the user a clear way out of the destructive prompt.
|
||||
deleteConfirm.value = false;
|
||||
return;
|
||||
}
|
||||
attemptClose();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener("keydown", handleKeydown));
|
||||
onUnmounted(() => document.removeEventListener("keydown", handleKeydown));
|
||||
|
||||
// ── Close / save flow ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// All exit paths (X button, Esc, backdrop click) funnel through `attemptClose`.
|
||||
// The Save button is gone — explicit-commit is replaced with auto-save-on-close.
|
||||
//
|
||||
// Validity-aware behavior:
|
||||
// - Form valid → save (PATCH for edit, POST for create), then close.
|
||||
// - Form invalid in EDIT mode → discard the in-memory change and close.
|
||||
// A toast tells the user what happened so they don't think their edit
|
||||
// silently landed.
|
||||
// - Form invalid in CREATE mode → close silently (nothing existed to begin
|
||||
// with; no need to call this out).
|
||||
|
||||
function isFormValid(): { valid: boolean; reason?: string } {
|
||||
if (!title.value.trim()) {
|
||||
return { valid: false, reason: "Title required" };
|
||||
}
|
||||
if (!startDate.value) {
|
||||
return { valid: false, reason: "Start date required" };
|
||||
}
|
||||
if (!allDay.value && !startTime.value) {
|
||||
return { valid: false, reason: "Start time required" };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
let _closing = false;
|
||||
|
||||
async function attemptClose() {
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
try {
|
||||
const validity = isFormValid();
|
||||
if (!validity.valid) {
|
||||
if (isEditMode.value) {
|
||||
toast.show(`${validity.reason} — change discarded`, "warning");
|
||||
}
|
||||
// Create mode + invalid: silent close. Nothing was committed.
|
||||
emit("close");
|
||||
return;
|
||||
}
|
||||
await save();
|
||||
emit("close");
|
||||
} finally {
|
||||
_closing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
|
||||
const end_dt = endDate.value
|
||||
? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value))
|
||||
: undefined;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEditMode.value && props.event) {
|
||||
const payload: EventUpdatePayload = {
|
||||
title: title.value.trim(),
|
||||
start_dt,
|
||||
end_dt,
|
||||
all_day: allDay.value,
|
||||
description: description.value,
|
||||
location: location.value,
|
||||
color: color.value,
|
||||
project_id: projectId.value ?? undefined,
|
||||
recurrence: recurrence.value || null,
|
||||
};
|
||||
const updated = await updateEvent(props.event.id, payload);
|
||||
emit("updated", updated);
|
||||
} else {
|
||||
const payload: EventCreatePayload = {
|
||||
title: title.value.trim(),
|
||||
start_dt,
|
||||
end_dt,
|
||||
all_day: allDay.value,
|
||||
description: description.value,
|
||||
location: location.value,
|
||||
color: color.value,
|
||||
project_id: projectId.value ?? undefined,
|
||||
recurrence: recurrence.value || undefined,
|
||||
};
|
||||
const created = await createEvent(payload);
|
||||
emit("created", created);
|
||||
}
|
||||
} catch {
|
||||
toast.show("Failed to save event", "error");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!props.event) return;
|
||||
deleting.value = true;
|
||||
try {
|
||||
await deleteEvent(props.event.id);
|
||||
toast.show("Event deleted", "success");
|
||||
emit("deleted", props.event.id);
|
||||
} catch {
|
||||
toast.show("Failed to delete event", "error");
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="modal-backdrop" @click.self="attemptClose">
|
||||
<div class="modal-panel" role="dialog" aria-modal="true">
|
||||
<!-- Header: trash + close (or inline delete-confirm) -->
|
||||
<div class="modal-header">
|
||||
<template v-if="!deleteConfirm">
|
||||
<h2 class="modal-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
class="header-btn header-btn-danger"
|
||||
@click="deleteConfirm = true"
|
||||
title="Delete event"
|
||||
aria-label="Delete event"
|
||||
><Trash2 :size="16" /></button>
|
||||
<button
|
||||
class="header-btn"
|
||||
@click="attemptClose"
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
><X :size="16" /></button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="delete-confirm-prompt">Delete this event?</span>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-danger"
|
||||
:disabled="deleting"
|
||||
@click="doDelete"
|
||||
>{{ deleting ? "Deleting…" : "Yes, delete" }}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-confirm-cancel"
|
||||
@click="deleteConfirm = false"
|
||||
>No</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Body: form (scrolls if it gets long) -->
|
||||
<form class="modal-form" @submit.prevent="attemptClose">
|
||||
<!-- Title -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Title <span class="required">*</span></label>
|
||||
<input v-model="title" class="form-input" placeholder="Event title" autofocus />
|
||||
</div>
|
||||
|
||||
<!-- All-day toggle -->
|
||||
<div class="form-field form-field-row">
|
||||
<label class="form-label form-label-inline">All day</label>
|
||||
<button
|
||||
type="button"
|
||||
:class="['toggle-btn', { active: allDay }]"
|
||||
@click="allDay = !allDay"
|
||||
>{{ allDay ? "Yes" : "No" }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Start -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Start <span class="required">*</span></label>
|
||||
<div class="dt-row">
|
||||
<input v-model="startDate" type="date" class="form-input dt-date" required />
|
||||
<input v-if="!allDay" v-model="startTime" type="time" class="form-input dt-time" required />
|
||||
</div>
|
||||
<p v-if="isPastEvent" class="form-past-hint">This event is in the past</p>
|
||||
</div>
|
||||
|
||||
<!-- End -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">End</label>
|
||||
<div class="dt-row">
|
||||
<input v-model="endDate" type="date" class="form-input dt-date" :min="startDate" />
|
||||
<input v-if="!allDay" v-model="endTime" type="time" class="form-input dt-time" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recurrence -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Repeat</label>
|
||||
<select v-model="recurrencePreset" class="form-input">
|
||||
<option value="none">Does not repeat</option>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
<option v-if="isCustomRecurrence" value="custom" disabled>Custom</option>
|
||||
</select>
|
||||
<p v-if="isCustomRecurrence" class="recurrence-custom-hint">
|
||||
Custom rule: <code>{{ recurrence }}</code>
|
||||
<br />
|
||||
<span class="form-hint">Picking a preset will replace this rule.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Location <span class="form-hint">(optional)</span></label>
|
||||
<input v-model="location" class="form-input" placeholder="Location" />
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Description <span class="form-hint">(optional)</span></label>
|
||||
<textarea v-model="description" class="form-input form-textarea" placeholder="Description" rows="3" />
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div class="form-field form-field-row">
|
||||
<label class="form-label form-label-inline">Color</label>
|
||||
<div class="color-row">
|
||||
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
|
||||
<input v-model="color" class="form-input color-hex" placeholder="#5B4A8A" />
|
||||
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"><X :size="16" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Project -->
|
||||
<div class="form-field">
|
||||
<label class="form-label">Project <span class="form-hint">(optional)</span></label>
|
||||
<ProjectSelector v-model="projectId" />
|
||||
</div>
|
||||
|
||||
<!-- A hidden submit so Enter inside text inputs triggers attemptClose,
|
||||
matching the no-explicit-Save-button intent: Enter commits. -->
|
||||
<button type="submit" class="hidden-submit" :disabled="saving" tabindex="-1" aria-hidden="true" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-panel {
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 12px;
|
||||
width: min(480px, 100%);
|
||||
max-height: calc(100vh - 2.5rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem 0.85rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #2a2b30);
|
||||
background: var(--color-surface, #1a1b1e);
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted, #888);
|
||||
cursor: pointer;
|
||||
padding: 0.4rem;
|
||||
border-radius: 6px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.header-btn:hover {
|
||||
background: var(--color-hover, rgba(255,255,255,0.06));
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
/* Trash in header: subtle until hover, then Oxblood. Lower visual weight
|
||||
than Save would have been — destructive actions shouldn't loom. */
|
||||
.header-btn-danger:hover {
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Inline delete-confirm prompt replaces the title row */
|
||||
.delete-confirm-prompt {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Form scrolls inside the panel when content overflows */
|
||||
.modal-form {
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-field { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.form-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
|
||||
|
||||
.form-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.form-label-inline { flex-shrink: 0; margin: 0; }
|
||||
.form-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
|
||||
|
||||
.required { color: #f87171; }
|
||||
|
||||
.form-input {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text, #e8e9f0);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
font-size: 0.9rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.form-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
|
||||
.form-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
|
||||
|
||||
.dt-row { display: flex; gap: 0.5rem; }
|
||||
.dt-date { flex: 1; }
|
||||
.dt-time { width: 7.5rem; flex-shrink: 0; }
|
||||
.form-past-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.recurrence-custom-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.recurrence-custom-hint code {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
background: var(--color-input-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text-muted, #888);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.color-row { display: flex; align-items: center; gap: 0.5rem; flex: 1; }
|
||||
.color-picker { width: 2.4rem; height: 2.2rem; border: none; padding: 0; border-radius: 4px; cursor: pointer; flex-shrink: 0; }
|
||||
.color-hex { flex: 1; }
|
||||
.btn-clear-color {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted, #888);
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.3rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Confirm-delete buttons (only shown during the inline confirm flow) */
|
||||
.btn-danger {
|
||||
background: var(--color-action-destructive);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
|
||||
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-confirm-cancel {
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-confirm-cancel:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* Hidden submit lets Enter-in-text-input trigger the same close-with-save
|
||||
path as X / Esc / backdrop. No visible Save button needed. */
|
||||
.hidden-submit {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0,0,0,0);
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -281,7 +281,7 @@ onMounted(loadVersions);
|
||||
|
||||
<div class="history-footer">
|
||||
<button
|
||||
class="btn-restore"
|
||||
class="btn-primary"
|
||||
:disabled="!selectedVersion?.body"
|
||||
@click="restore"
|
||||
>Restore this version</button>
|
||||
@@ -393,19 +393,6 @@ onMounted(loadVersions);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-restore {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-restore:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Pin badges + label rendering ───────────────────────────────────────── */
|
||||
.pin-badge {
|
||||
@@ -416,12 +403,12 @@ onMounted(loadVersions);
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
}
|
||||
.pin-badge-manual { color: var(--color-primary, #6366f1); }
|
||||
.pin-badge-auto { color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); }
|
||||
.pin-badge-manual { color: var(--color-primary); }
|
||||
.pin-badge-auto { color: var(--color-text-muted); }
|
||||
|
||||
.history-item-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-primary, #6366f1);
|
||||
color: var(--color-primary);
|
||||
font-style: italic;
|
||||
margin-top: 0.15rem;
|
||||
overflow: hidden;
|
||||
@@ -443,7 +430,7 @@ onMounted(loadVersions);
|
||||
}
|
||||
.pin-state {
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
|
||||
color: var(--color-text-muted);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -455,13 +442,13 @@ onMounted(loadVersions);
|
||||
font-size: 0.78rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-unpin:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.10);
|
||||
@@ -476,27 +463,27 @@ onMounted(loadVersions);
|
||||
flex: 1;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: var(--color-input-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: inherit;
|
||||
}
|
||||
.pin-label-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-pin-save, .btn-pin-cancel {
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-pin-save:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
|
||||
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
|
||||
|
||||
@@ -135,8 +135,8 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.iap-btn-cancel:hover {
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.iap-stream-preview {
|
||||
@@ -188,22 +188,22 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-weight: var(--fs-weight-medium);
|
||||
}
|
||||
.iap-btn-accept {
|
||||
background: var(--color-success, #22c55e);
|
||||
color: #fff;
|
||||
background: var(--color-success);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.iap-btn-accept:hover { opacity: 0.85; }
|
||||
|
||||
.iap-btn-reject {
|
||||
background: var(--color-bg-card, var(--color-bg));
|
||||
background: var(--color-bg-card);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.iap-btn-reject:hover {
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ── Diff ── */
|
||||
@@ -226,12 +226,12 @@ const markers: Record<DiffLine["type"], string> = {
|
||||
|
||||
.iap-diff-equal { color: var(--color-text-muted); }
|
||||
.iap-diff-delete {
|
||||
background: color-mix(in srgb, var(--color-danger, #e74c3c) 10%, transparent);
|
||||
color: var(--color-danger, #e74c3c);
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.iap-diff-insert {
|
||||
background: color-mix(in srgb, var(--color-success, #22c55e) 10%, transparent);
|
||||
color: var(--color-success, #22c55e);
|
||||
background: color-mix(in srgb, var(--color-success) 10%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.iap-diff-marker {
|
||||
|
||||
@@ -64,11 +64,11 @@ function goEdit() {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--color-primary) 6%, transparent);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.note-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--color-primary) 14.0%, transparent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ function goEdit() {
|
||||
}
|
||||
.note-card.compact:hover {
|
||||
box-shadow: none;
|
||||
background: rgba(91, 74, 138, 0.04);
|
||||
background: color-mix(in srgb, var(--color-primary) 4%, transparent);
|
||||
transform: none;
|
||||
}
|
||||
.note-title-compact {
|
||||
|
||||
@@ -81,8 +81,8 @@ onUnmounted(() => {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
background: var(--color-danger, #ef4444);
|
||||
color: #fff;
|
||||
background: var(--color-danger);
|
||||
color: var(--fs-text-on-action);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
min-width: 16px;
|
||||
|
||||
@@ -50,7 +50,7 @@ onMounted(() => store.fetchAll())
|
||||
<span class="notif-panel-title">Notifications</span>
|
||||
<button
|
||||
v-if="store.count > 0"
|
||||
class="btn-mark-all"
|
||||
class="btn-text"
|
||||
@click="store.markAll()"
|
||||
>Mark all read</button>
|
||||
</header>
|
||||
@@ -70,7 +70,7 @@ onMounted(() => store.fetchAll())
|
||||
</p>
|
||||
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
|
||||
</div>
|
||||
<button class="btn-notif-close" @click.stop="store.markRead(n.id)" aria-label="Dismiss"><X :size="16" /></button>
|
||||
<button class="btn-text" @click.stop="store.markRead(n.id)" aria-label="Dismiss"><X :size="16" /></button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="notif-empty">No unread notifications</div>
|
||||
@@ -108,21 +108,6 @@ onMounted(() => store.fetchAll())
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-mark-all {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.btn-mark-all:hover { text-decoration: underline; }
|
||||
|
||||
.notif-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notif-item {
|
||||
display: flex;
|
||||
@@ -148,22 +133,4 @@ onMounted(() => store.fetchAll())
|
||||
}
|
||||
.notif-time { font-size: 0.75rem; color: var(--color-muted); }
|
||||
|
||||
.btn-notif-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.25rem;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.1s;
|
||||
}
|
||||
.btn-notif-close:hover { color: var(--color-text); }
|
||||
|
||||
.notif-empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -90,7 +90,7 @@ function goToPage(page: number) {
|
||||
}
|
||||
.page-btn.active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.ellipsis {
|
||||
|
||||
@@ -159,7 +159,7 @@ const calendarDayMax = computed(() =>
|
||||
.rec-num-input {
|
||||
width: 4rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border: 1px solid var(--color-input-border, var(--color-border));
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
|
||||
@@ -117,7 +117,7 @@ onMounted(async () => {
|
||||
<div class="share-dialog" role="dialog" :aria-label="`Share ${resourceTitle}`">
|
||||
<header class="share-header">
|
||||
<h2 class="share-title">Share "{{ resourceTitle }}"</h2>
|
||||
<button class="btn-close" @click="emit('close')" aria-label="Close"><X :size="16" /></button>
|
||||
<button class="btn-text" @click="emit('close')" aria-label="Close"><X :size="16" /></button>
|
||||
</header>
|
||||
|
||||
<!-- Add share form -->
|
||||
@@ -184,7 +184,7 @@ onMounted(async () => {
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button class="btn-remove-share" @click="removeShare(share)" aria-label="Remove"><X :size="16" /></button>
|
||||
<button class="btn-text" @click="removeShare(share)" aria-label="Remove"><X :size="16" /></button>
|
||||
</li>
|
||||
<li v-if="!shares.length" class="shares-empty">Not shared with anyone yet</li>
|
||||
</ul>
|
||||
@@ -231,23 +231,6 @@ onMounted(async () => {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.btn-close:hover { color: var(--color-text); }
|
||||
|
||||
.share-add {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.share-tabs {
|
||||
display: flex;
|
||||
@@ -268,7 +251,7 @@ onMounted(async () => {
|
||||
.share-tab.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
|
||||
.share-target-form {
|
||||
@@ -339,11 +322,11 @@ onMounted(async () => {
|
||||
.btn-add-share {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
font-weight: var(--fs-weight-medium);
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
@@ -394,23 +377,4 @@ onMounted(async () => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-remove-share {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-remove-share:hover { color: var(--color-danger, #ef4444); }
|
||||
|
||||
.shares-empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The starter token ROLES offered when a design system is created (#2349).
|
||||
*
|
||||
* WHY THIS IS A COMPONENT
|
||||
* DesignSystemsView has two creation forms — the empty state and the one inside
|
||||
* the body — because the empty state is a sibling branch, not a parent. Putting
|
||||
* the checklist inline would make it the third thing in this codebase defined
|
||||
* twice and free to drift, which is what the whole button migration was about.
|
||||
*
|
||||
* WHAT IT OFFERS
|
||||
* Names and purposes, never values. A role is a question the operator answers
|
||||
* with their own palette; a default palette would be one install's taste
|
||||
* shipped as product (rule #115). Every group is individually skippable —
|
||||
* an operator who wants three tokens should get three.
|
||||
*
|
||||
* All groups are checked by default. That default lives HERE rather than in the
|
||||
* service, because the service must never seed rows into a system whose caller
|
||||
* did not ask; a UI default is visible and reversible before the click.
|
||||
*/
|
||||
import { onMounted, ref } from "vue";
|
||||
import { listStarterRoleGroups, type StarterRoleGroup } from "@/api/designSystems";
|
||||
|
||||
// props + emit rather than defineModel, matching TagInput and the rest of
|
||||
// components/ — being the only file using a different binding idiom costs more
|
||||
// than the few lines it saves.
|
||||
const props = defineProps<{ selected: string[]; prefix: string }>();
|
||||
const emit = defineEmits<{
|
||||
"update:selected": [value: string[]];
|
||||
"update:prefix": [value: string];
|
||||
}>();
|
||||
|
||||
const groups = ref<StarterRoleGroup[]>([]);
|
||||
const defaultPrefix = ref("--ds-");
|
||||
const loading = ref(false);
|
||||
const failed = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await listStarterRoleGroups();
|
||||
groups.value = data.groups;
|
||||
defaultPrefix.value = data.default_prefix;
|
||||
if (!props.prefix) emit("update:prefix", data.default_prefix);
|
||||
// Everything on by default — see the note above.
|
||||
if (!props.selected.length) {
|
||||
emit("update:selected", data.groups.map((g) => g.group));
|
||||
}
|
||||
} catch {
|
||||
// A creation form must still work when this fails. Roles are an
|
||||
// accelerator, not a prerequisite: the operator can add tokens by hand.
|
||||
failed.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function toggle(group: string) {
|
||||
emit(
|
||||
"update:selected",
|
||||
props.selected.includes(group)
|
||||
? props.selected.filter((g) => g !== group)
|
||||
: [...props.selected, group],
|
||||
);
|
||||
}
|
||||
|
||||
const totalTokens = () =>
|
||||
groups.value
|
||||
.filter((g) => props.selected.includes(g.group))
|
||||
.reduce((n, g) => n + g.token_count, 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="srp-note">Loading starter roles…</div>
|
||||
|
||||
<!-- Failure is not fatal and should not read as one. -->
|
||||
<div v-else-if="failed" class="srp-note">
|
||||
Starter roles unavailable — you can add tokens by hand after creating.
|
||||
</div>
|
||||
|
||||
<fieldset v-else-if="groups.length" class="srp">
|
||||
<legend class="srp-legend">Start with these token roles</legend>
|
||||
<p class="srp-intro">
|
||||
Named now, valued later. A role you haven't filled in shows as
|
||||
<em>to be decided</em>; a role that doesn't exist is what gets written as a
|
||||
literal instead. Uncheck anything this system won't have.
|
||||
</p>
|
||||
|
||||
<div class="srp-grid">
|
||||
<label v-for="g in groups" :key="g.group" class="srp-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="props.selected.includes(g.group)"
|
||||
@change="toggle(g.group)"
|
||||
/>
|
||||
<span class="srp-name">{{ g.group }}</span>
|
||||
<span class="srp-count">{{ g.token_count }}</span>
|
||||
<span class="srp-desc">{{ g.description }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="srp-footer">
|
||||
<label class="srp-prefix">
|
||||
<span>Prefix</span>
|
||||
<input
|
||||
:value="props.prefix" class="input srp-prefix-input" type="text"
|
||||
:placeholder="defaultPrefix"
|
||||
@input="emit('update:prefix', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
<span class="srp-total">
|
||||
{{ totalTokens() }} {{ totalTokens() === 1 ? "role" : "roles" }}, no values
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.srp {
|
||||
border: var(--fs-border);
|
||||
border-radius: var(--fs-radius-md);
|
||||
padding: var(--fs-space-4);
|
||||
margin: 0 0 var(--fs-space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.srp-legend {
|
||||
font-size: var(--fs-size-label);
|
||||
font-weight: var(--fs-weight-medium);
|
||||
color: var(--color-text);
|
||||
padding: 0 var(--fs-space-2);
|
||||
}
|
||||
|
||||
.srp-intro,
|
||||
.srp-note {
|
||||
margin: 0 0 var(--fs-space-3);
|
||||
font-size: var(--fs-size-body-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: var(--fs-leading-body);
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.srp-note {
|
||||
margin-bottom: var(--fs-space-4);
|
||||
}
|
||||
|
||||
.srp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
|
||||
gap: var(--fs-space-2);
|
||||
}
|
||||
|
||||
.srp-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
align-items: baseline;
|
||||
gap: var(--fs-space-2);
|
||||
padding: var(--fs-space-1) var(--fs-space-2);
|
||||
border-radius: var(--fs-radius-sm);
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
.srp-item:hover { background: var(--color-hover); }
|
||||
|
||||
.srp-name {
|
||||
font-size: var(--fs-size-body-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.srp-count {
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* The description is the useful part on a wide card and the first thing worth
|
||||
dropping on a narrow one — the group name alone still identifies the row. */
|
||||
.srp-desc {
|
||||
grid-column: 1 / -1;
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--color-text-muted);
|
||||
line-height: var(--fs-leading-body);
|
||||
}
|
||||
|
||||
.srp-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--fs-space-3);
|
||||
margin-top: var(--fs-space-4);
|
||||
}
|
||||
|
||||
.srp-prefix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-2);
|
||||
font-size: var(--fs-size-body-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.srp-prefix-input {
|
||||
width: 8rem;
|
||||
font-family: var(--fs-font-mono);
|
||||
font-size: var(--fs-size-code);
|
||||
}
|
||||
|
||||
.srp-total {
|
||||
font-size: var(--fs-size-tiny);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,515 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useSystemsStore } from "@/stores/systems";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { getProjectIssues } from "@/api/systems";
|
||||
import type { System, TaskLike } from "@/api/systems";
|
||||
import { Pencil, Trash2, Archive, ArchiveRestore } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{ projectId: number }>();
|
||||
|
||||
const store = useSystemsStore();
|
||||
const toast = useToastStore();
|
||||
|
||||
const error = ref<string | null>(null);
|
||||
const showArchived = ref(false);
|
||||
const issues = ref<TaskLike[]>([]);
|
||||
|
||||
// Create state
|
||||
const showCreate = ref(false);
|
||||
const newName = ref("");
|
||||
const newDescription = ref("");
|
||||
const creating = ref(false);
|
||||
|
||||
// Edit state
|
||||
const editingId = ref<number | null>(null);
|
||||
const editName = ref("");
|
||||
const editDescription = ref("");
|
||||
const savingEdit = ref(false);
|
||||
|
||||
// Delete confirmation
|
||||
const deletingSystem = ref<System | null>(null);
|
||||
|
||||
const systems = computed<System[]>(() => store.systemsByProject[props.projectId] ?? []);
|
||||
const activeSystems = computed(() => systems.value.filter((s) => s.status === "active"));
|
||||
const archivedSystems = computed(() => systems.value.filter((s) => s.status === "archived"));
|
||||
const visibleSystems = computed(() =>
|
||||
showArchived.value ? systems.value : activeSystems.value,
|
||||
);
|
||||
|
||||
async function load() {
|
||||
error.value = null;
|
||||
try {
|
||||
await store.fetchSystems(props.projectId);
|
||||
} catch {
|
||||
error.value = "Failed to load systems.";
|
||||
}
|
||||
try {
|
||||
issues.value = await getProjectIssues(props.projectId);
|
||||
} catch {
|
||||
issues.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(() => props.projectId, load);
|
||||
|
||||
function openCreate() {
|
||||
showCreate.value = true;
|
||||
newName.value = "";
|
||||
newDescription.value = "";
|
||||
}
|
||||
|
||||
function cancelCreate() {
|
||||
showCreate.value = false;
|
||||
newName.value = "";
|
||||
newDescription.value = "";
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
const name = newName.value.trim();
|
||||
if (!name || creating.value) return;
|
||||
creating.value = true;
|
||||
try {
|
||||
await store.createSystem(props.projectId, {
|
||||
name,
|
||||
description: newDescription.value.trim() || undefined,
|
||||
});
|
||||
cancelCreate();
|
||||
toast.show("System created");
|
||||
} catch {
|
||||
toast.show("Failed to create system", "error");
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(system: System) {
|
||||
editingId.value = system.id;
|
||||
editName.value = system.name;
|
||||
editDescription.value = system.description;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null;
|
||||
}
|
||||
|
||||
async function submitEdit(system: System) {
|
||||
const name = editName.value.trim();
|
||||
if (!name || savingEdit.value) return;
|
||||
savingEdit.value = true;
|
||||
try {
|
||||
await store.updateSystem(props.projectId, system.id, {
|
||||
name,
|
||||
description: editDescription.value.trim(),
|
||||
});
|
||||
editingId.value = null;
|
||||
toast.show("System updated");
|
||||
} catch {
|
||||
toast.show("Failed to update system", "error");
|
||||
} finally {
|
||||
savingEdit.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function archive(system: System) {
|
||||
try {
|
||||
await store.archiveSystem(props.projectId, system.id);
|
||||
toast.show("System archived");
|
||||
} catch {
|
||||
toast.show("Failed to archive system", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function unarchive(system: System) {
|
||||
try {
|
||||
await store.unarchiveSystem(props.projectId, system.id);
|
||||
toast.show("System restored");
|
||||
} catch {
|
||||
toast.show("Failed to restore system", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const system = deletingSystem.value;
|
||||
if (!system) return;
|
||||
deletingSystem.value = null;
|
||||
try {
|
||||
await store.deleteSystem(props.projectId, system.id);
|
||||
toast.show("System deleted");
|
||||
} catch {
|
||||
toast.show("Failed to delete system", "error");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="systems-section">
|
||||
<!-- Open issues -->
|
||||
<div v-if="issues.length" class="open-issues">
|
||||
<div class="open-issues-label">⚠ Open issues ({{ issues.length }})</div>
|
||||
<ul class="issue-list">
|
||||
<li v-for="issue in issues" :key="issue.id" class="issue-item">
|
||||
<router-link :to="`/tasks/${issue.id}`" class="issue-link">
|
||||
<span class="issue-mark" :class="`imk-${issue.status}`">{{ issue.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||
<span class="issue-name">{{ issue.title }}</span>
|
||||
<span v-if="issue.systems && issue.systems.length" class="issue-systems">
|
||||
<span v-for="s in issue.systems" :key="s.id" class="issue-sys-chip">{{ s.name }}</span>
|
||||
</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="systems-toolbar">
|
||||
<button v-if="!showCreate" class="btn-ghost btn-inline btn-add-system" @click="openCreate">
|
||||
+ System
|
||||
</button>
|
||||
<label v-if="archivedSystems.length" class="archived-toggle">
|
||||
<input v-model="showArchived" type="checkbox" class="archived-checkbox" />
|
||||
Show archived ({{ archivedSystems.length }})
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Create form -->
|
||||
<form v-if="showCreate" class="system-form" @submit.prevent="submitCreate">
|
||||
<input
|
||||
v-model="newName"
|
||||
class="system-input"
|
||||
placeholder="System name"
|
||||
aria-label="System name"
|
||||
autofocus
|
||||
@keydown.escape="cancelCreate"
|
||||
/>
|
||||
<textarea
|
||||
v-model="newDescription"
|
||||
class="system-textarea"
|
||||
rows="2"
|
||||
placeholder="What is this subsystem responsible for? (optional)"
|
||||
aria-label="System description"
|
||||
></textarea>
|
||||
<div class="system-form-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
|
||||
{{ creating ? "Creating…" : "Create" }}
|
||||
</button>
|
||||
<button type="button" class="btn-ghost btn-compact" @click="cancelCreate">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.loading && !systems.length" class="systems-skeleton" aria-label="Loading systems">
|
||||
<div class="skel-row"></div>
|
||||
<div class="skel-row skel-row--short"></div>
|
||||
<div class="skel-row"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="!visibleSystems.length" class="systems-empty">
|
||||
<p class="empty-title">No systems yet</p>
|
||||
<p class="empty-sub">Define a reusable subsystem or area to organize issues against.</p>
|
||||
<button v-if="!showCreate" class="btn-primary btn-compact" @click="openCreate">+ Create a system</button>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<ul v-else class="systems-list">
|
||||
<li
|
||||
v-for="system in visibleSystems"
|
||||
:key="system.id"
|
||||
class="system-card"
|
||||
:class="{ 'system-card--archived': system.status === 'archived' }"
|
||||
>
|
||||
<!-- Inline edit -->
|
||||
<template v-if="editingId === system.id">
|
||||
<form class="system-form system-form--inline" @submit.prevent="submitEdit(system)">
|
||||
<input
|
||||
v-model="editName"
|
||||
class="system-input"
|
||||
placeholder="System name"
|
||||
aria-label="System name"
|
||||
autofocus
|
||||
@keydown.escape="cancelEdit"
|
||||
/>
|
||||
<textarea
|
||||
v-model="editDescription"
|
||||
class="system-textarea"
|
||||
rows="2"
|
||||
placeholder="Description (optional)"
|
||||
aria-label="System description"
|
||||
></textarea>
|
||||
<div class="system-form-actions">
|
||||
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
|
||||
{{ savingEdit ? "Saving…" : "Save" }}
|
||||
</button>
|
||||
<button type="button" class="btn-ghost btn-compact" @click="cancelEdit">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<!-- Display -->
|
||||
<template v-else>
|
||||
<span
|
||||
class="system-swatch"
|
||||
:style="{ background: system.color || 'var(--color-text-muted)' }"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<div class="system-body">
|
||||
<div class="system-name-row">
|
||||
<span class="system-name">{{ system.name }}</span>
|
||||
<span
|
||||
class="issue-badge"
|
||||
:title="`${system.open_issue_count} open issue(s)`"
|
||||
>{{ system.open_issue_count }} open</span>
|
||||
<span v-if="system.status === 'archived'" class="archived-badge">Archived</span>
|
||||
</div>
|
||||
<p v-if="system.description" class="system-description">{{ system.description }}</p>
|
||||
</div>
|
||||
<div class="system-actions">
|
||||
<button class="action-btn" title="Edit" aria-label="Edit system" @click="startEdit(system)">
|
||||
<Pencil :size="16" />
|
||||
</button>
|
||||
<button
|
||||
v-if="system.status === 'active'"
|
||||
class="action-btn"
|
||||
title="Archive"
|
||||
aria-label="Archive system"
|
||||
@click="archive(system)"
|
||||
>
|
||||
<Archive :size="16" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="action-btn"
|
||||
title="Restore"
|
||||
aria-label="Restore system"
|
||||
@click="unarchive(system)"
|
||||
>
|
||||
<ArchiveRestore :size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="action-btn action-delete"
|
||||
title="Delete"
|
||||
aria-label="Delete system"
|
||||
@click="deletingSystem = system"
|
||||
>
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Delete confirmation -->
|
||||
<teleport to="body">
|
||||
<div v-if="deletingSystem" class="modal-overlay" @click.self="deletingSystem = null">
|
||||
<div class="modal-card">
|
||||
<h3 class="modal-title">Delete System</h3>
|
||||
<p class="modal-message">
|
||||
Delete <strong>{{ deletingSystem.name }}</strong>? This cannot be undone.
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-btn" @click="deletingSystem = null">Cancel</button>
|
||||
<button class="modal-btn modal-btn-danger" @click="confirmDelete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.systems-section { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
|
||||
/* ── Open issues ──────────────────────────────────────────────── */
|
||||
.open-issues { display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.open-issues-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--color-text-muted); }
|
||||
.issue-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.2rem; }
|
||||
.issue-link { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: var(--radius-sm); text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
|
||||
.issue-link:hover { background: var(--color-bg-secondary); }
|
||||
.issue-mark { color: var(--color-text-muted); flex-shrink: 0; }
|
||||
.issue-mark.imk-in_progress { color: var(--color-primary); }
|
||||
.issue-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.issue-systems { display: flex; gap: 0.25rem; flex-shrink: 0; flex-wrap: wrap; }
|
||||
.issue-sys-chip { font-size: 0.66rem; color: var(--color-text-secondary); background: var(--color-bg-secondary); border-radius: 999px; padding: 0.05rem 0.4rem; }
|
||||
|
||||
/* ── Toolbar ──────────────────────────────────────────────────── */
|
||||
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
||||
.btn-add-system {
|
||||
background: none;
|
||||
border: 1px dashed var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0.28rem 0.65rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-add-system:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-add-system:focus-visible { outline: none; border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.archived-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.archived-checkbox { accent-color: var(--color-primary); cursor: pointer; }
|
||||
|
||||
/* ── Create / edit form ───────────────────────────────────────── */
|
||||
.system-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
|
||||
.system-input, .system-textarea {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--color-primary); }
|
||||
.system-textarea { resize: vertical; }
|
||||
|
||||
.system-form-actions { display: flex; gap: 0.4rem; }
|
||||
.system-card--archived { opacity: 0.6; }
|
||||
|
||||
.system-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
.system-body { flex: 1; min-width: 0; }
|
||||
.system-name-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.system-name { font-weight: 500; color: var(--color-text); word-break: break-word; }
|
||||
.issue-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
color: var(--color-primary);
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.45rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.archived-badge {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0.05rem 0.45rem;
|
||||
}
|
||||
.system-description {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.system-actions { display: flex; gap: 0.15rem; flex-shrink: 0; opacity: 0; transition: opacity 0.15s; }
|
||||
.system-card:hover .system-actions,
|
||||
.system-card:focus-within .system-actions { opacity: 1; }
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.action-btn:hover { background: var(--color-bg-secondary); color: var(--color-text); }
|
||||
.action-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; opacity: 1; }
|
||||
.action-delete:hover { color: var(--color-danger); }
|
||||
|
||||
/* ── Empty ────────────────────────────────────────────────────── */
|
||||
.systems-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.empty-title { margin: 0; font-weight: 500; color: var(--color-text); }
|
||||
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--color-text-muted); max-width: 32ch; }
|
||||
|
||||
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
|
||||
|
||||
/* ── Skeleton ─────────────────────────────────────────────────── */
|
||||
@keyframes skel-shine { to { background-position: 200% center; } }
|
||||
.systems-skeleton { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.skel-row {
|
||||
height: 3rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-bg-secondary) 25%,
|
||||
color-mix(in srgb, var(--color-text-muted) 16%, var(--color-bg-secondary)) 50%,
|
||||
var(--color-bg-secondary) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skel-shine 1.5s ease infinite;
|
||||
}
|
||||
.skel-row--short { width: 65%; }
|
||||
|
||||
/* ── Modal ────────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: var(--color-overlay);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.modal-card {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 8px 32px var(--color-shadow);
|
||||
}
|
||||
.modal-title { margin: 0 0 0.75rem; font-size: 1.05rem; }
|
||||
.modal-message { font-size: 0.9rem; color: var(--color-text-secondary); margin: 0 0 1.25rem; line-height: 1.5; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
|
||||
.modal-btn {
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-btn:hover { background: var(--color-bg); }
|
||||
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: var(--fs-text-on-action); }
|
||||
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
|
||||
</style>
|
||||
@@ -222,7 +222,7 @@ function focusInput() {
|
||||
}
|
||||
.tag-autocomplete-item:hover,
|
||||
.tag-autocomplete-item.selected {
|
||||
background: var(--color-bg-hover, color-mix(in srgb, var(--color-primary) 8%, transparent));
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -112,11 +112,11 @@ function isOverdue(): boolean {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
background: var(--color-bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px color-mix(in srgb, var(--color-primary) 6%, transparent);
|
||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||
}
|
||||
.task-card:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px color-mix(in srgb, var(--color-primary) 14.0%, transparent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@@ -144,19 +144,19 @@ function isOverdue(): boolean {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.dot-todo {
|
||||
background: var(--color-status-todo, #94a3b8);
|
||||
border: 2px solid var(--color-status-todo, #94a3b8);
|
||||
background: var(--color-status-todo);
|
||||
border: 2px solid var(--color-status-todo);
|
||||
background: transparent;
|
||||
border: 2px solid var(--color-text-muted);
|
||||
}
|
||||
.dot-in-progress {
|
||||
background: var(--color-status-in-progress, #3b82f6);
|
||||
background: var(--color-status-in-progress);
|
||||
}
|
||||
.dot-done {
|
||||
background: var(--color-status-done, #22c55e);
|
||||
background: var(--color-status-done);
|
||||
}
|
||||
.dot-cancelled {
|
||||
background: var(--color-status-cancelled, #6b7280);
|
||||
background: var(--color-status-cancelled);
|
||||
}
|
||||
|
||||
.task-title-compact {
|
||||
@@ -190,7 +190,7 @@ function isOverdue(): boolean {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.due-compact.overdue {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Full layout */
|
||||
|
||||
@@ -122,8 +122,8 @@ onMounted(loadLogs);
|
||||
class="log-duration-input"
|
||||
placeholder="min"
|
||||
/>
|
||||
<button class="btn-log-save" @click="saveEdit(log)">Save</button>
|
||||
<button class="btn-log-cancel" @click="cancelEdit">Cancel</button>
|
||||
<button class="btn-primary btn-compact" @click="saveEdit(log)">Save</button>
|
||||
<button class="btn-ghost btn-compact" @click="cancelEdit">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -133,8 +133,8 @@ onMounted(loadLogs);
|
||||
{{ formatDuration(log.duration_minutes) }}
|
||||
</span>
|
||||
<div class="log-entry-actions">
|
||||
<button class="btn-log-edit" @click="startEdit(log)" title="Edit">Edit</button>
|
||||
<button class="btn-log-delete" aria-label="Delete log entry" @click="deleteLog(log)">×</button>
|
||||
<button class="btn-text" @click="startEdit(log)" title="Edit">Edit</button>
|
||||
<button class="btn-text" aria-label="Delete log entry" @click="deleteLog(log)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-content prose" v-html="renderMarkdown(log.content)"></div>
|
||||
@@ -162,7 +162,7 @@ onMounted(loadLogs);
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
class="btn-log-submit"
|
||||
class="btn-primary btn-compact"
|
||||
@click="submitLog"
|
||||
:disabled="!newContent.trim() || submitting"
|
||||
>
|
||||
@@ -217,7 +217,7 @@ onMounted(loadLogs);
|
||||
|
||||
.log-duration-badge {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
border-radius: 99px;
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
@@ -230,34 +230,7 @@ onMounted(loadLogs);
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-log-edit,
|
||||
.btn-log-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
padding: 0.1rem 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-log-edit:hover { color: var(--color-primary); }
|
||||
.btn-log-delete:hover { color: var(--color-danger, #e74c3c); }
|
||||
|
||||
.log-content {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.log-content :deep(p) { margin: 0; }
|
||||
|
||||
.log-add {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.log-textarea {
|
||||
width: 100%;
|
||||
@@ -307,32 +280,6 @@ onMounted(loadLogs);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-log-submit,
|
||||
.btn-log-save {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn-log-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-log-cancel {
|
||||
padding: 0.3rem 0.6rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,7 +36,7 @@ const toastStore = useToastStore();
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 2px 8px var(--color-shadow);
|
||||
min-width: 200px;
|
||||
@@ -54,7 +54,7 @@ const toastStore = useToastStore();
|
||||
padding: 0 0.15rem;
|
||||
}
|
||||
.toast-close:hover {
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.toast--success {
|
||||
background: var(--color-toast-success);
|
||||
|
||||
@@ -236,12 +236,12 @@ function restore() {
|
||||
.vh-btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.vh-btn-restore {
|
||||
background: var(--color-primary);
|
||||
background: var(--color-action-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.25rem 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface ForecastDay {
|
||||
day: string
|
||||
condition: string
|
||||
high: number
|
||||
low: number
|
||||
precip_probability: number | null
|
||||
precip_mm: number | null
|
||||
windspeed_max: number
|
||||
precip_summary?: string
|
||||
precip_peak_hour?: string
|
||||
}
|
||||
|
||||
interface WeatherData {
|
||||
location: string
|
||||
fetched_at: string
|
||||
current_temp: number
|
||||
condition: string
|
||||
today_high: number | null
|
||||
today_low: number | null
|
||||
yesterday_high: number | null
|
||||
yesterday_low: number | null
|
||||
wind_unit?: string
|
||||
precip_summary?: string | null
|
||||
forecast: ForecastDay[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
weather: WeatherData | null
|
||||
tempUnit?: string
|
||||
}>()
|
||||
|
||||
function weatherIcon(condition: string): string {
|
||||
const c = condition.toLowerCase()
|
||||
if (c.includes('thunderstorm')) return '⛈️'
|
||||
if (c.includes('hail')) return '🌨️'
|
||||
if (c.includes('snow showers')) return '🌨️'
|
||||
if (c.includes('snow')) return '❄️'
|
||||
if (c.includes('rain showers: violent')) return '⛈️'
|
||||
if (c.includes('rain showers')) return '🌦️'
|
||||
if (c.includes('drizzle') || c.includes('rain')) return '🌧️'
|
||||
if (c.includes('fog')) return '🌫️'
|
||||
if (c.includes('overcast')) return '☁️'
|
||||
if (c.includes('partly cloudy')) return '⛅'
|
||||
if (c.includes('mainly clear')) return '🌤️'
|
||||
if (c.includes('clear')) return '☀️'
|
||||
return '🌡️'
|
||||
}
|
||||
|
||||
const unit = computed(() => props.tempUnit ?? 'C')
|
||||
|
||||
const tempDelta = computed(() => {
|
||||
const w = props.weather
|
||||
if (!w || w.today_high == null || w.yesterday_high == null) return null
|
||||
const diff = w.today_high - w.yesterday_high
|
||||
if (Math.abs(diff) < 1) return 'Same as yesterday'
|
||||
const dir = diff > 0 ? 'warmer' : 'cooler'
|
||||
return `${Math.abs(diff)}° ${dir} than yesterday`
|
||||
})
|
||||
|
||||
const fetchedAtLabel = computed(() => {
|
||||
if (!props.weather?.fetched_at) return ''
|
||||
try {
|
||||
return new Date(props.weather.fetched_at).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
function hasPrecip(day: ForecastDay): boolean {
|
||||
return (day.precip_probability != null && day.precip_probability > 0) ||
|
||||
(day.precip_mm != null && day.precip_mm > 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="weather" class="weather-card">
|
||||
<div class="weather-header">
|
||||
<span class="weather-location">{{ weather.location }}</span>
|
||||
<span class="weather-fetched-at">as of {{ fetchedAtLabel }}</span>
|
||||
</div>
|
||||
<div class="weather-current">
|
||||
<span class="weather-icon">{{ weatherIcon(weather.condition) }}</span>
|
||||
<span class="weather-temp">{{ weather.current_temp }}°{{ unit }}</span>
|
||||
<span class="weather-condition">{{ weather.condition }}</span>
|
||||
</div>
|
||||
<div class="weather-today" v-if="weather.today_high != null">
|
||||
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
|
||||
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
|
||||
</div>
|
||||
<div v-if="weather.precip_summary" class="weather-precip-summary">
|
||||
💧 {{ weather.precip_summary }}
|
||||
</div>
|
||||
<table class="weather-forecast" v-if="weather.forecast.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th>Hi / Lo</th>
|
||||
<th>💧</th>
|
||||
<th>💨 {{ weather.wind_unit ?? 'km/h' }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="day in weather.forecast" :key="day.day">
|
||||
<td class="forecast-day-name">{{ day.day }}</td>
|
||||
<td class="forecast-icon">{{ weatherIcon(day.condition) }}</td>
|
||||
<td class="forecast-temps">{{ day.high }}° / {{ day.low }}°</td>
|
||||
<td class="forecast-precip" :class="{ 'forecast-precip--dry': !hasPrecip(day) }">
|
||||
<template v-if="day.precip_summary">
|
||||
<span class="precip-detail" :title="day.precip_summary">
|
||||
{{ day.precip_probability }}%
|
||||
<span v-if="day.precip_peak_hour" class="precip-peak">{{ day.precip_peak_hour }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="day.precip_probability != null && day.precip_probability > 0">{{ day.precip_probability }}%</template>
|
||||
<template v-else-if="day.precip_mm != null && day.precip_mm > 0">{{ day.precip_mm.toFixed(1) }}mm</template>
|
||||
<template v-else>—</template>
|
||||
</td>
|
||||
<td class="forecast-wind">{{ day.windspeed_max }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="weather-card weather-unavailable">
|
||||
Weather data unavailable — will retry at next slot.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.weather-card {
|
||||
background: color-mix(in srgb, var(--color-surface) 80%, transparent);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.9rem;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.weather-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-location {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.weather-fetched-at {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.weather-current {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.weather-icon {
|
||||
font-size: clamp(1.5rem, 5cqi, 2.5rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.weather-temp {
|
||||
font-size: clamp(1.5rem, 5cqi, 2.5rem);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.weather-condition {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.weather-today {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.weather-delta {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.weather-precip-summary {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.83rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
border-left: 2px solid color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
}
|
||||
|
||||
.weather-forecast {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.weather-forecast thead th {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
padding: 0.5rem 0.4rem 0.25rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.weather-forecast thead th:first-child,
|
||||
.weather-forecast thead th:nth-child(2) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.weather-forecast tbody td {
|
||||
padding: 0.3rem 0.4rem;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.forecast-day-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.forecast-icon {
|
||||
font-size: clamp(0.9rem, 3cqi, 1.3rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.forecast-temps {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.forecast-precip {
|
||||
text-align: right;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.forecast-precip--dry {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.precip-detail {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3em;
|
||||
}
|
||||
|
||||
.precip-peak {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.forecast-wind {
|
||||
text-align: right;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.weather-unavailable {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -314,14 +314,14 @@ defineExpose({ reload: loadProjectNotes });
|
||||
@keydown.enter="createNote"
|
||||
@keydown.escape="cancelNewNote"
|
||||
/>
|
||||
<button class="btn-confirm" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
|
||||
<button class="btn-primary btn-inline" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
|
||||
{{ creatingNote ? '…' : '+' }}
|
||||
</button>
|
||||
<button class="btn-cancel" aria-label="Cancel new note" @click="cancelNewNote"><X :size="16" /></button>
|
||||
<button class="btn-text" aria-label="Cancel new note" @click="cancelNewNote"><X :size="16" /></button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="rail-title">Notes</span>
|
||||
<button class="btn-new-note" @click="startNewNote" title="New note">+ New</button>
|
||||
<button class="btn-ghost btn-inline" @click="startNewNote" title="New note">+ New</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -333,7 +333,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
type="search"
|
||||
aria-label="Search notes"
|
||||
/>
|
||||
<button v-if="searchQuery" class="btn-search-clear" aria-label="Clear search" @click="searchQuery = ''"><X :size="16" /></button>
|
||||
<button v-if="searchQuery" class="btn-text btn-search-clear" aria-label="Clear search" @click="searchQuery = ''"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div v-if="listLoading" class="rail-state">Loading…</div>
|
||||
@@ -358,12 +358,12 @@ defineExpose({ reload: loadProjectNotes });
|
||||
</div>
|
||||
<div class="note-row-actions" @click.stop>
|
||||
<template v-if="deletingId === note.id">
|
||||
<button class="btn-confirm-delete" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
|
||||
<button class="btn-danger-outline btn-inline" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
|
||||
{{ pendingDelete === note.id ? '…' : 'Delete?' }}
|
||||
</button>
|
||||
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)"><X :size="16" /></button>
|
||||
<button class="btn-text" aria-label="Cancel delete" @click="cancelDelete($event)"><X :size="16" /></button>
|
||||
</template>
|
||||
<button v-else class="btn-delete" title="Delete note" @click="requestDelete(note.id, $event)">
|
||||
<button v-else class="btn-text" title="Delete note" @click="requestDelete(note.id, $event)">
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -382,7 +382,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<WordCount :body="noteBody" />
|
||||
<span v-if="dirty && !saving" class="unsaved">Unsaved</span>
|
||||
<span v-if="saving" class="saving-txt">Saving…</span>
|
||||
<button class="btn-save" :disabled="saving || !dirty" @click="saveNote">Save</button>
|
||||
<button class="btn-primary btn-compact" :disabled="saving || !dirty" @click="saveNote">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -400,7 +400,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<div class="tag-row">
|
||||
<TagInput v-model="noteTags" :fetchTags="(q: string) => notesStore.fetchAllTags(q)" />
|
||||
<button
|
||||
class="btn-suggest-tags"
|
||||
class="btn-ghost btn-compact"
|
||||
:disabled="tagSuggestions.suggestingTags.value"
|
||||
title="Auto-suggest tags from title and body"
|
||||
@click="tagSuggestions.fetchTagSuggestions()"
|
||||
@@ -417,7 +417,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
:class="['btn-tag-suggestion', { applied: tagSuggestions.appliedTags.value.has(tag) }]"
|
||||
@click="tagSuggestions.applyTagSuggestion(tag)"
|
||||
>#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"><X :size="16" /></button>
|
||||
<button class="btn-text" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-row">
|
||||
@@ -429,8 +429,8 @@ defineExpose({ reload: loadProjectNotes });
|
||||
<span v-for="s in linkSuggestions" :key="s.note_id" class="link-suggest-chip" :title="`Appears ${s.count}× unlinked`">
|
||||
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
|
||||
</span>
|
||||
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
|
||||
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"><X :size="16" /></button>
|
||||
<button class="btn-ghost btn-inline" @click="applyAllLinks" title="Link all suggestions">All</button>
|
||||
<button class="btn-text" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
|
||||
@@ -463,7 +463,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--color-bg-card, var(--color-bg-secondary));
|
||||
background: var(--color-bg-card);
|
||||
}
|
||||
|
||||
.rail-header {
|
||||
@@ -484,26 +484,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn-new-note {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-new-note:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.rail-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rail-search-input {
|
||||
flex: 1;
|
||||
@@ -517,17 +497,8 @@ defineExpose({ reload: loadProjectNotes });
|
||||
.rail-search-input:focus { outline: none; }
|
||||
.rail-search-input::-webkit-search-cancel-button { display: none; }
|
||||
|
||||
.btn-search-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.68rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-search-clear:hover { color: var(--color-text); }
|
||||
/* Sits inside the search field: no padding, and must not flex. */
|
||||
.btn-search-clear { padding: 0; flex-shrink: 0; }
|
||||
|
||||
.rail-state {
|
||||
padding: 1rem 0.65rem;
|
||||
@@ -617,99 +588,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.1rem;
|
||||
border-radius: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s, color 0.1s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.note-row:hover .btn-delete { opacity: 1; }
|
||||
.btn-delete:hover { color: var(--color-action-destructive); }
|
||||
|
||||
.btn-confirm-delete {
|
||||
background: none;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-confirm-delete:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
|
||||
.btn-confirm-delete:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
.btn-cancel-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem;
|
||||
}
|
||||
.btn-cancel-delete:hover { color: var(--color-text); }
|
||||
|
||||
/* Inline new note */
|
||||
.new-note-input {
|
||||
flex: 1;
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.new-note-input:focus { outline: none; }
|
||||
|
||||
.btn-confirm {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.15rem 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-confirm:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.btn-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-cancel:hover { color: var(--color-text); }
|
||||
|
||||
/* ── Right editor pane ── */
|
||||
.note-editor-pane {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor-empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Editor UI */
|
||||
.panel-header {
|
||||
@@ -731,23 +610,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
.saving-txt { font-size: 0.72rem; color: var(--color-primary); }
|
||||
|
||||
/* Moss action-primary per Hybrid */
|
||||
.btn-save {
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-save:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-save:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.note-title-row {
|
||||
padding: 0.9rem 1.1rem 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.note-title-input {
|
||||
width: 100%;
|
||||
@@ -775,20 +637,7 @@ defineExpose({ reload: loadProjectNotes });
|
||||
}
|
||||
.tag-row > :first-child { flex: 1; min-width: 0; }
|
||||
|
||||
.btn-suggest-tags {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 5px;
|
||||
padding: 0.25rem 0.55rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
.btn-suggest-tags:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.btn-suggest-tags:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-suggest-tags { flex-shrink: 0; align-self: center; }
|
||||
|
||||
.tag-suggestions {
|
||||
display: flex;
|
||||
@@ -817,21 +666,6 @@ defineExpose({ reload: loadProjectNotes });
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-dismiss-suggestions {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
margin-left: auto;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
.btn-dismiss-suggestions:hover { color: var(--color-text); }
|
||||
|
||||
.toolbar-row {
|
||||
padding: 0.3rem 0.6rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-suggest-strip {
|
||||
display: flex;
|
||||
@@ -865,21 +699,4 @@ defineExpose({ reload: loadProjectNotes });
|
||||
}
|
||||
.btn-chip-link:hover { background: color-mix(in srgb, var(--color-primary) 15%, transparent); }
|
||||
|
||||
.btn-link-all {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
margin-left: 0.1rem;
|
||||
}
|
||||
.btn-link-all:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.editor-area {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0.6rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -232,7 +232,7 @@ defineExpose({ reload: loadAll });
|
||||
placeholder="New task..."
|
||||
@keydown.enter="addTask"
|
||||
/>
|
||||
<button class="btn-add" :disabled="addingTask || !newTaskTitle.trim()" @click="addTask">+</button>
|
||||
<button class="btn-primary btn-inline btn-add" :disabled="addingTask || !newTaskTitle.trim()" @click="addTask">+</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="state-msg">Loading...</div>
|
||||
@@ -293,18 +293,18 @@ defineExpose({ reload: loadAll });
|
||||
<Transition name="detail-fade">
|
||||
<div v-if="activeTask" class="task-detail">
|
||||
<div class="detail-header">
|
||||
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-edit-task" title="Open full editor">Edit ↗</RouterLink>
|
||||
<RouterLink :to="`/tasks/${activeTask.id}/edit`" target="_blank" class="btn-text btn-edit-task" title="Open full editor">Edit ↗</RouterLink>
|
||||
<span :class="['status-badge', `status-${activeTask.status}`]" @click="cycleStatus(activeTask, $event)" title="Click to cycle status">
|
||||
{{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }}
|
||||
</span>
|
||||
<template v-if="deleteConfirmPending">
|
||||
<button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
|
||||
<button class="btn-delete-cancel" aria-label="Cancel delete" @click="cancelDeleteTask"><X :size="16" /></button>
|
||||
<button class="btn-danger-outline btn-inline btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
|
||||
<button class="btn-text" aria-label="Cancel delete" @click="cancelDeleteTask"><X :size="16" /></button>
|
||||
</template>
|
||||
<button v-else class="btn-delete-task" title="Delete task" @click="deleteActiveTask">
|
||||
<button v-else class="btn-text btn-delete-task" title="Delete task" @click="deleteActiveTask">
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
<button class="btn-close-detail" @click="closeTask" aria-label="Close detail"><X :size="16" /></button>
|
||||
<button class="btn-text btn-close-detail" @click="closeTask" aria-label="Close detail"><X :size="16" /></button>
|
||||
</div>
|
||||
|
||||
<h3 class="detail-title">{{ activeTask.title }}</h3>
|
||||
@@ -387,7 +387,7 @@ defineExpose({ reload: loadAll });
|
||||
|
||||
.task-add-input {
|
||||
flex: 1;
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
background: var(--color-input-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 5px;
|
||||
padding: 0.28rem 0.5rem;
|
||||
@@ -396,17 +396,7 @@ defineExpose({ reload: loadAll });
|
||||
}
|
||||
.task-add-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
|
||||
.btn-add {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
padding: 0.28rem 0.55rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-add:disabled { opacity: 0.4; cursor: default; }
|
||||
.btn-add { font-size: 1rem; } /* a '+' glyph, not a label */
|
||||
|
||||
.groups-scroll {
|
||||
flex: 1;
|
||||
@@ -423,7 +413,7 @@ defineExpose({ reload: loadAll });
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.65rem;
|
||||
background: var(--color-surface-raised, color-mix(in srgb, var(--color-surface) 92%, var(--color-text)));
|
||||
background: var(--color-surface-raised);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
@@ -443,7 +433,7 @@ defineExpose({ reload: loadAll });
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.ms-status-active { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
|
||||
.ms-status-completed { background: color-mix(in srgb, var(--color-success, #27ae60) 15%, transparent); color: var(--color-success, #27ae60); }
|
||||
.ms-status-completed { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
||||
|
||||
.task-items {
|
||||
list-style: none;
|
||||
@@ -476,7 +466,7 @@ defineExpose({ reload: loadAll });
|
||||
justify-content: center;
|
||||
}
|
||||
.status-dot.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
.status-dot.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); }
|
||||
.status-dot.status-done { border-color: var(--color-success); color: var(--color-success); }
|
||||
|
||||
.task-title {
|
||||
flex: 1;
|
||||
@@ -532,19 +522,9 @@ defineExpose({ reload: loadAll });
|
||||
margin-left: auto;
|
||||
}
|
||||
.status-badge.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 10%, transparent); }
|
||||
.status-badge.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); background: color-mix(in srgb, var(--color-success, #27ae60) 10%, transparent); }
|
||||
.status-badge.status-done { border-color: var(--color-success); color: var(--color-success); background: color-mix(in srgb, var(--color-success) 10%, transparent); }
|
||||
|
||||
.btn-edit-task {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-edit-task { margin-left: 0.25rem; }
|
||||
.btn-edit-task:hover { text-decoration: underline; }
|
||||
|
||||
.detail-body {
|
||||
@@ -566,51 +546,11 @@ defineExpose({ reload: loadAll });
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-delete-task {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.btn-delete-task { margin-left: 0.25rem; }
|
||||
.btn-delete-task:hover { color: var(--color-action-destructive); }
|
||||
|
||||
.btn-delete-confirm {
|
||||
background: none;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-left: 0.25rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-delete-confirm:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
|
||||
.btn-delete-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-delete-confirm { margin-left: 0.25rem; }
|
||||
|
||||
.btn-delete-cancel {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
}
|
||||
.btn-delete-cancel:hover { color: var(--color-text); }
|
||||
|
||||
.detail-title {
|
||||
padding: 0.75rem 0.75rem 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
@@ -674,20 +614,10 @@ defineExpose({ reload: loadAll });
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-due.overdue {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
color: var(--color-danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Close detail button */
|
||||
.btn-close-detail {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
margin-left: 0.2rem;
|
||||
}
|
||||
.btn-close-detail:hover { color: var(--color-text); }
|
||||
.btn-close-detail { margin-left: 0.2rem; }
|
||||
</style>
|
||||
|
||||
@@ -46,7 +46,7 @@ watch(() => props.projectId, load);
|
||||
<style scoped>
|
||||
.plan-rules {
|
||||
margin-top: 1.5rem;
|
||||
border-top: 1px solid var(--color-border, #2a2a2e);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.plan-rules h3 {
|
||||
@@ -60,7 +60,7 @@ watch(() => props.projectId, load);
|
||||
}
|
||||
.plan-rules ul {
|
||||
list-style: none; padding-left: 0.75rem; margin: 0.25rem 0;
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
}
|
||||
.plan-rules li { margin: 0.35rem 0; font-size: 0.92em; }
|
||||
.truncated { opacity: 0.7; font-style: italic; font-size: 0.85em; }
|
||||
|
||||
@@ -329,7 +329,7 @@ h3 {
|
||||
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||||
.chip {
|
||||
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||
background: var(--color-primary-bg, rgba(99,102,241,0.15));
|
||||
background: var(--color-primary-bg);
|
||||
padding: 0.25rem 0.5rem; border-radius: 999px;
|
||||
}
|
||||
.chip a { cursor: pointer; }
|
||||
@@ -337,13 +337,13 @@ h3 {
|
||||
.chip-remove:hover { opacity: 1; }
|
||||
.add {
|
||||
background: none;
|
||||
border: 1px dashed var(--color-border, #2a2a2e);
|
||||
border: 1px dashed var(--color-border);
|
||||
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
|
||||
color: inherit;
|
||||
}
|
||||
select {
|
||||
background: var(--color-bg, #111113); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
background: var(--color-bg); color: inherit;
|
||||
border: 1px solid var(--color-border); border-radius: 6px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.applicable { margin-top: 2rem; }
|
||||
@@ -355,7 +355,7 @@ select {
|
||||
}
|
||||
ul { list-style: none; padding: 0; margin: 0; }
|
||||
.rule {
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
padding-left: 0.75rem; margin: 0.5rem 0;
|
||||
}
|
||||
.rule-head { cursor: pointer; }
|
||||
@@ -363,12 +363,12 @@ ul { list-style: none; padding: 0; margin: 0; }
|
||||
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
|
||||
.rule-detail {
|
||||
margin-top: 0.5rem; padding: 0.5rem;
|
||||
background: var(--color-bg, #111113); border-radius: 6px;
|
||||
background: var(--color-bg); border-radius: 6px;
|
||||
}
|
||||
.rule-detail > div { margin-bottom: 0.5rem; }
|
||||
.edit-link {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--color-primary, #6366f1); padding: 0.5rem 0 0 0;
|
||||
color: var(--color-primary); padding: 0.5rem 0 0 0;
|
||||
}
|
||||
.empty, .truncated { opacity: 0.7; font-style: italic; }
|
||||
.empty a { cursor: pointer; text-decoration: underline; }
|
||||
@@ -377,18 +377,18 @@ ul { list-style: none; padding: 0; margin: 0; }
|
||||
.new-rule-form {
|
||||
display: flex; flex-direction: column; gap: 0.5rem;
|
||||
padding: 0.75rem; margin: 0.5rem 0;
|
||||
background: var(--color-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border); border-radius: 6px;
|
||||
}
|
||||
.new-rule-form input, .new-rule-form textarea {
|
||||
background: var(--color-surface, #18181b); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
background: var(--color-surface); color: inherit;
|
||||
border: 1px solid var(--color-border); border-radius: 6px;
|
||||
padding: 0.5rem; font: inherit; resize: vertical;
|
||||
}
|
||||
.rule-list { margin-top: 0.5rem; }
|
||||
.delete-link {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--color-destructive, #b85a4a); padding: 0.5rem 0 0 0;
|
||||
color: var(--color-destructive); padding: 0.5rem 0 0 0;
|
||||
}
|
||||
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
|
||||
.topic-group h5 {
|
||||
@@ -400,14 +400,14 @@ ul { list-style: none; padding: 0; margin: 0; }
|
||||
.rule-head-text { flex: 1; cursor: pointer; }
|
||||
.skip-btn {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--color-muted, #888); font-size: 0.75rem;
|
||||
color: var(--color-muted); font-size: 0.75rem;
|
||||
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.topic-group h5:hover .skip-btn,
|
||||
.rule:hover .skip-btn,
|
||||
.skip-btn:focus { opacity: 1; }
|
||||
.skip-btn:hover { color: var(--color-destructive, #b85a4a); }
|
||||
.skip-btn:hover { color: var(--color-destructive); }
|
||||
/* Suppressed section */
|
||||
.suppressed { margin-top: 1.5rem; }
|
||||
.suppressed-toggle {
|
||||
@@ -426,13 +426,13 @@ ul { list-style: none; padding: 0; margin: 0; }
|
||||
.suppressed-kind {
|
||||
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.4rem; border-radius: 3px;
|
||||
background: var(--color-bg, #111113);
|
||||
border: 1px solid var(--color-border, #2a2a2e);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.suppressed-path { flex: 1; }
|
||||
.reenable-btn {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--color-primary, #6366f1); font-size: 0.85em;
|
||||
color: var(--color-primary); font-size: 0.85em;
|
||||
}
|
||||
.reenable-btn:hover { text-decoration: underline; }
|
||||
</style>
|
||||
|
||||
@@ -98,8 +98,8 @@ watch(() => props.ruleId, load);
|
||||
.slide-over {
|
||||
position: fixed; top: 0; right: 0; bottom: 0;
|
||||
width: min(520px, 90vw);
|
||||
background: var(--color-surface, #18181b);
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
background: var(--color-surface);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
|
||||
@@ -110,11 +110,11 @@ header h2 {
|
||||
font-family: Fraunces, serif; font-style: italic;
|
||||
}
|
||||
label { display: block; margin-bottom: 1rem; }
|
||||
.required { color: var(--color-primary, #6366f1); }
|
||||
.required { color: var(--color-primary); }
|
||||
input, textarea {
|
||||
width: 100%; margin-top: 0.25rem;
|
||||
background: var(--color-bg, #111113); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
background: var(--color-bg); color: inherit;
|
||||
border: 1px solid var(--color-border); border-radius: 6px;
|
||||
padding: 0.5rem; font: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@@ -22,18 +22,18 @@ const emit = defineEmits<{
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||
.pane { background: var(--color-surface); padding: 1rem; overflow-y: auto; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
li {
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||
li:hover { background: var(--color-hover); }
|
||||
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
||||
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
||||
.new-rule { cursor: pointer; }
|
||||
|
||||
@@ -122,7 +122,7 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||
.pane { background: var(--color-surface); padding: 1rem; overflow-y: auto; }
|
||||
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
.always-on-toggle {
|
||||
@@ -133,18 +133,18 @@ header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem
|
||||
.always-on-toggle input { cursor: pointer; }
|
||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||
li.active { background: var(--color-primary-bg); }
|
||||
li:hover { background: var(--color-hover); }
|
||||
.new-topic input {
|
||||
width: 100%; margin-bottom: 0.5rem;
|
||||
background: var(--color-bg, #111113); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
background: var(--color-bg); color: inherit;
|
||||
border: 1px solid var(--color-border); border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.form-buttons { display: flex; gap: 0.5rem; }
|
||||
.subscriptions {
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid var(--color-border, #2a2a2e);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
|
||||
@@ -48,27 +48,27 @@ async function submitNew() {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
||||
.pane { background: var(--color-surface); padding: 1rem; overflow-y: auto; }
|
||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; display: flex; align-items: center; gap: 0.5rem; }
|
||||
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
||||
li.active { background: var(--color-primary-bg); }
|
||||
li:hover { background: var(--color-hover); }
|
||||
.always-on-badge {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
background: var(--color-accent, rgba(91,74,138,0.25));
|
||||
color: var(--color-accent-fg, inherit);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-fg);
|
||||
margin-left: auto;
|
||||
}
|
||||
.new-rulebook { margin-top: 1rem; }
|
||||
.new-rulebook input {
|
||||
width: 100%; margin-bottom: 0.5rem;
|
||||
background: var(--color-bg, #111113); color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
||||
background: var(--color-bg); color: inherit;
|
||||
border: 1px solid var(--color-border); border-radius: 6px;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.form-buttons { display: flex; gap: 0.5rem; }
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "./assets/theme.css";
|
||||
// After theme.css — it consumes the tokens declared there.
|
||||
import "./assets/components.css";
|
||||
import "./assets/prose.css";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
@@ -69,6 +69,26 @@ const router = createRouter({
|
||||
name: "note-edit",
|
||||
component: () => import("@/views/NoteEditorView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/snippets",
|
||||
name: "snippets",
|
||||
component: () => import("@/views/SnippetListView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/snippets/new",
|
||||
name: "snippet-new",
|
||||
component: () => import("@/views/SnippetEditorView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/snippets/:id",
|
||||
name: "snippet-view",
|
||||
component: () => import("@/views/SnippetDetailView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/snippets/:id/edit",
|
||||
name: "snippet-edit",
|
||||
component: () => import("@/views/SnippetEditorView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/graph",
|
||||
name: "graph",
|
||||
@@ -89,6 +109,20 @@ const router = createRouter({
|
||||
name: "rules",
|
||||
component: () => import("@/views/RulesView.vue"),
|
||||
},
|
||||
{
|
||||
// Meta-surface, same family as /rules: it describes the app rather than
|
||||
// holding the operator's records.
|
||||
path: "/design",
|
||||
name: "design",
|
||||
component: () => import("@/views/DesignView.vue"),
|
||||
},
|
||||
{
|
||||
// The editable half of the same surface: /design is what the browser
|
||||
// renders, /design-systems is the record that ought to decide it.
|
||||
path: "/design-systems",
|
||||
name: "design-systems",
|
||||
component: () => import("@/views/DesignSystemsView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/tasks",
|
||||
redirect: "/",
|
||||
@@ -108,11 +142,6 @@ const router = createRouter({
|
||||
name: "shared-with-me",
|
||||
component: () => import("@/views/SharedWithMeView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/calendar",
|
||||
name: "calendar",
|
||||
component: () => import("@/views/CalendarView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
name: "settings",
|
||||
|
||||
@@ -31,7 +31,6 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
project_id?: number | null;
|
||||
milestone_id?: number | null;
|
||||
note_type?: string;
|
||||
metadata?: Record<string, string> | null;
|
||||
}): Promise<Note> {
|
||||
try {
|
||||
return await apiPost<Note>("/api/notes", data);
|
||||
@@ -43,7 +42,7 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
|
||||
async function updateNote(
|
||||
id: number,
|
||||
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type" | "metadata">>
|
||||
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type">>
|
||||
): Promise<Note> {
|
||||
try {
|
||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import * as api from "@/api/systems";
|
||||
import type { System } from "@/api/systems";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
export const useSystemsStore = defineStore("systems", () => {
|
||||
const systemsByProject = ref<Record<number, System[]>>({});
|
||||
const loading = ref(false);
|
||||
|
||||
async function fetchSystems(projectId: number) {
|
||||
loading.value = true;
|
||||
try {
|
||||
systemsByProject.value[projectId] = await api.listSystems(projectId);
|
||||
} catch (e) {
|
||||
useToastStore().show("Failed to load systems", "error");
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSystem(
|
||||
projectId: number,
|
||||
data: { name: string; description?: string; color?: string },
|
||||
) {
|
||||
const system = await api.createSystem(projectId, data);
|
||||
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
|
||||
systemsByProject.value[projectId].push(system);
|
||||
return system;
|
||||
}
|
||||
|
||||
async function updateSystem(
|
||||
projectId: number,
|
||||
systemId: number,
|
||||
data: Partial<Pick<System, "name" | "description" | "color" | "status" | "order_index">>,
|
||||
) {
|
||||
const system = await api.updateSystem(projectId, systemId, data);
|
||||
const list = systemsByProject.value[projectId];
|
||||
if (list) {
|
||||
const idx = list.findIndex((s) => s.id === systemId);
|
||||
if (idx >= 0) list[idx] = system;
|
||||
}
|
||||
return system;
|
||||
}
|
||||
|
||||
async function archiveSystem(projectId: number, systemId: number) {
|
||||
return updateSystem(projectId, systemId, { status: "archived" });
|
||||
}
|
||||
|
||||
async function unarchiveSystem(projectId: number, systemId: number) {
|
||||
return updateSystem(projectId, systemId, { status: "active" });
|
||||
}
|
||||
|
||||
async function deleteSystem(projectId: number, systemId: number) {
|
||||
await api.deleteSystem(projectId, systemId);
|
||||
const list = systemsByProject.value[projectId];
|
||||
if (list) {
|
||||
systemsByProject.value[projectId] = list.filter((s) => s.id !== systemId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
systemsByProject,
|
||||
loading,
|
||||
fetchSystems,
|
||||
createSystem,
|
||||
updateSystem,
|
||||
archiveSystem,
|
||||
unarchiveSystem,
|
||||
deleteSystem,
|
||||
};
|
||||
});
|
||||
@@ -3,6 +3,16 @@ import { defineStore } from "pinia";
|
||||
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import type { Task, TaskStatus, TaskPriority, StartPlanningResult } from "@/types/task";
|
||||
import type { TaskKind } from "@/types/note";
|
||||
|
||||
// Issues + Systems write-only fields accepted by the task create/update API.
|
||||
// `kind` selects work/plan/issue; system_ids / arose_from_id are not mirrored
|
||||
// 1:1 on the Task model (the read side exposes `systems` + `arose_from_id`).
|
||||
interface IssueFields {
|
||||
kind?: TaskKind;
|
||||
system_ids?: number[];
|
||||
arose_from_id?: number | null;
|
||||
}
|
||||
|
||||
// Single-task + mutation surface. The list/filter/sort/pagination surface that
|
||||
// backed the removed /tasks list view was dropped in the 2026-06-02 drift-audit
|
||||
@@ -34,7 +44,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
milestone_id?: number | null;
|
||||
parent_id?: number | null;
|
||||
recurrence_rule?: Record<string, unknown> | null;
|
||||
}): Promise<Task> {
|
||||
} & IssueFields): Promise<Task> {
|
||||
try {
|
||||
return await apiPost<Task>("/api/tasks", data);
|
||||
} catch (e) {
|
||||
@@ -47,7 +57,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
||||
id: number,
|
||||
data: Partial<
|
||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
|
||||
>
|
||||
> & IssueFields
|
||||
): Promise<Task> {
|
||||
try {
|
||||
const task = await apiPut<Task>(`/api/tasks/${id}`, data);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { System } from "@/api/systems";
|
||||
|
||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||
export type NoteType = "note" | "person" | "place" | "list" | "process";
|
||||
export type TaskKind = "work" | "plan" | "issue";
|
||||
export type NoteType = "note" | "process" | "snippet";
|
||||
|
||||
export interface Note {
|
||||
id: number;
|
||||
@@ -22,8 +25,9 @@ export interface Note {
|
||||
recurrence_next_spawn_at: string | null;
|
||||
is_task: boolean;
|
||||
note_type: NoteType;
|
||||
task_kind?: "work" | "plan";
|
||||
metadata: Record<string, string>;
|
||||
task_kind?: TaskKind;
|
||||
systems?: System[];
|
||||
arose_from_id?: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,18 @@ export interface TaskListResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
// start_planning now creates a MILESTONE (the plan container), not a kind=plan
|
||||
// task. The milestone's `body` holds the design; steps live as child tasks.
|
||||
export interface StartPlanningResult {
|
||||
task: import("./note").Note;
|
||||
milestone: {
|
||||
id: number;
|
||||
project_id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
body: string | null;
|
||||
status: string;
|
||||
order_index: number;
|
||||
};
|
||||
applicable_rules: {
|
||||
id: number;
|
||||
title: string;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Drift comparison — what the rulebook claims vs what the stylesheet does.
|
||||
*
|
||||
* Milestone #251 step 5. Deliberately thin: the hard half (turning rulebook
|
||||
* prose into claims) is server-side in `services/design_system.py`, where pytest
|
||||
* can assert on it. What's left here is set arithmetic over live token values,
|
||||
* which is the one thing the browser knows and the server doesn't.
|
||||
*
|
||||
* SCOPE, and it is a real limit rather than an omission. This compares the
|
||||
* rulebook against the TOKENS. It cannot see the third category of drift — a
|
||||
* literal hardcoded in a component where a token should be referenced (#2275,
|
||||
* 67 occurrences of `color: #fff` against a rule that forbids pure white). That
|
||||
* drift isn't in the tokens at all, so no amount of inspecting them finds it.
|
||||
*
|
||||
* Catching it needs the component sources, which would mean bundling every SFC
|
||||
* into the app to read at runtime — a large cost for a panel. It belongs in CI,
|
||||
* as a lint-shaped check, and is tracked there (#2277). Saying so in the panel
|
||||
* matters: a drift report that silently omits a category invites the reader to
|
||||
* conclude the category is clean.
|
||||
*/
|
||||
import type { DesignToken } from "@/utils/designTokens";
|
||||
|
||||
export type ExpectationKind = "token" | "color" | "prohibited_color";
|
||||
|
||||
export interface Expectation {
|
||||
kind: ExpectationKind;
|
||||
value: string;
|
||||
rule_id: number;
|
||||
rule_title: string;
|
||||
context: string;
|
||||
}
|
||||
|
||||
export interface ExpectationResponse {
|
||||
rulebook_id: number | null;
|
||||
expectations: Expectation[];
|
||||
}
|
||||
|
||||
export type FindingStatus = "ok" | "missing" | "violated";
|
||||
|
||||
export interface Finding {
|
||||
expectation: Expectation;
|
||||
status: FindingStatus;
|
||||
/** Tokens that satisfy (or, for a prohibition, breach) the expectation. */
|
||||
matches: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a colour for comparison — the client-side twin of
|
||||
* `normalize_hex` in services/design_system.py.
|
||||
*
|
||||
* These two MUST agree. The rulebook writes `#FFFFFF`, `theme.css` writes
|
||||
* `#fff`, and getComputedStyle hands back `rgb(255, 255, 255)` — three
|
||||
* spellings of one colour, and a comparison that misses any of them under-reports
|
||||
* rather than erroring. The rgb() case is browser-specific and therefore has no
|
||||
* server-side counterpart, which is exactly why it is handled here.
|
||||
*/
|
||||
export function normalizeColour(value: string): string | null {
|
||||
const raw = value.trim().toLowerCase();
|
||||
|
||||
const hex = /^#([0-9a-f]{3,8})$/.exec(raw);
|
||||
if (hex) {
|
||||
let digits = hex[1];
|
||||
if (digits.length === 3 || digits.length === 4) {
|
||||
digits = digits.split("").map((c) => c + c).join("");
|
||||
}
|
||||
return digits.length === 6 || digits.length === 8 ? `#${digits}` : null;
|
||||
}
|
||||
|
||||
// getComputedStyle always reports colours as rgb()/rgba(), never as authored.
|
||||
const rgb = /^rgba?\(([^)]+)\)$/.exec(raw);
|
||||
if (rgb) {
|
||||
const parts = rgb[1].split(/[,\s/]+/).filter(Boolean);
|
||||
if (parts.length < 3) return null;
|
||||
const channels = parts.slice(0, 3).map((p) => Number(p));
|
||||
if (channels.some((n) => !Number.isFinite(n))) return null;
|
||||
const hexOf = (n: number) => Math.round(n).toString(16).padStart(2, "0");
|
||||
const base = `#${channels.map(hexOf).join("")}`;
|
||||
if (parts.length === 3) return base;
|
||||
const alpha = Number(parts[3]);
|
||||
if (!Number.isFinite(alpha) || alpha >= 1) return base;
|
||||
return `${base}${hexOf(alpha * 255)}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Every distinct colour the stylesheet actually resolves to, mapped to its tokens. */
|
||||
export function colourIndex(tokens: DesignToken[]): Map<string, string[]> {
|
||||
const index = new Map<string, string[]>();
|
||||
for (const token of tokens) {
|
||||
const colour = normalizeColour(token.value);
|
||||
if (!colour) continue;
|
||||
const names = index.get(colour);
|
||||
if (names) names.push(token.name);
|
||||
else index.set(colour, [token.name]);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare claims against the live tokens.
|
||||
*
|
||||
* A `token` claim asks whether a custom property of that name exists.
|
||||
* A `color` claim asks whether any token resolves to that value.
|
||||
* A `prohibited_color` claim INVERTS the test — present is the failure.
|
||||
*/
|
||||
export function compareToTokens(
|
||||
expectations: Expectation[],
|
||||
tokens: DesignToken[],
|
||||
): Finding[] {
|
||||
const names = new Set(tokens.map((t) => t.name));
|
||||
const colours = colourIndex(tokens);
|
||||
|
||||
return expectations.map((expectation) => {
|
||||
if (expectation.kind === "token") {
|
||||
const present = names.has(expectation.value);
|
||||
return {
|
||||
expectation,
|
||||
status: present ? "ok" : "missing",
|
||||
matches: present ? [expectation.value] : [],
|
||||
};
|
||||
}
|
||||
|
||||
const matches = colours.get(expectation.value) ?? [];
|
||||
if (expectation.kind === "prohibited_color") {
|
||||
return {
|
||||
expectation,
|
||||
status: matches.length ? "violated" : "ok",
|
||||
matches,
|
||||
};
|
||||
}
|
||||
return {
|
||||
expectation,
|
||||
status: matches.length ? "ok" : "missing",
|
||||
matches,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface DriftSummary {
|
||||
ok: number;
|
||||
missing: number;
|
||||
violated: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function summarise(findings: Finding[]): DriftSummary {
|
||||
const summary: DriftSummary = { ok: 0, missing: 0, violated: 0, total: findings.length };
|
||||
for (const finding of findings) summary[finding.status] += 1;
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findings worth leading with.
|
||||
*
|
||||
* A panel that opens with every row gets closed and never reopened — the same
|
||||
* principle the auto-inject menu is built on: a short list that gets read beats
|
||||
* a complete one that doesn't. Violations first (something is actively wrong),
|
||||
* then missing (something was never built), and `ok` rows are not "findings" at
|
||||
* all — they belong behind an expansion.
|
||||
*/
|
||||
export function rankFindings(findings: Finding[]): Finding[] {
|
||||
const order: Record<FindingStatus, number> = { violated: 0, missing: 1, ok: 2 };
|
||||
return [...findings].sort((a, b) => {
|
||||
const byStatus = order[a.status] - order[b.status];
|
||||
if (byStatus !== 0) return byStatus;
|
||||
return a.expectation.rule_id - b.expectation.rule_id;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Design-token inventory — what tokens exist, and what they actually resolve to.
|
||||
*
|
||||
* Foundation for the design explorer (milestone #251): the gallery renders
|
||||
* against these, and the drift panel compares them to the design rulebook.
|
||||
*
|
||||
* DESIGN NOTE — why this parses NAMES but never VALUES.
|
||||
* Extracting `--foo` from a stylesheet is a trivial, robust regex. Extracting
|
||||
* its VALUE is not: values contain nested parens, commas inside rgba(),
|
||||
* `var()` references to other tokens, multi-part shadows, and gradients — and
|
||||
* `theme.css` has all of those today. So we take the names from the source and
|
||||
* ask the BROWSER for every value.
|
||||
*
|
||||
* That is not just easier, it is more correct. getComputedStyle reports what
|
||||
* actually won the cascade, resolves `var()` chains, and — critically for this
|
||||
* milestone — reflects live overrides set on a container, which is exactly what
|
||||
* the preview surface needs (see #2261). Parsing the source would report what
|
||||
* the file says rather than what the user is looking at.
|
||||
*
|
||||
* It also means this module needs no unit tests to be trustworthy: the only
|
||||
* logic here is a name regex and a group lookup. The frontend has no test
|
||||
* runner today (`vue-tsc --noEmit` is the whole check), so keeping the
|
||||
* error-prone half in the browser rather than in our code is deliberate.
|
||||
*/
|
||||
import themeCss from "@/assets/theme.css?raw";
|
||||
|
||||
export type TokenGroup =
|
||||
| "color"
|
||||
| "radius"
|
||||
| "gradient"
|
||||
| "glow"
|
||||
| "focus"
|
||||
| "layout"
|
||||
| "other";
|
||||
|
||||
export type ThemeMode = "light" | "dark";
|
||||
|
||||
export interface DesignToken {
|
||||
/** Full custom-property name, including the leading `--`. */
|
||||
name: string;
|
||||
/** Coarse family, derived from the name prefix. */
|
||||
group: TokenGroup;
|
||||
/** Resolved value in the requested context, straight from the browser. */
|
||||
value: string;
|
||||
/** True when the declaration appears inside the dark block in source. */
|
||||
overriddenInDark: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a custom-property DECLARATION, and never a `var(--name)` use.
|
||||
*
|
||||
* The discriminator is the COLON, not the preceding character. A declaration is
|
||||
* `--name:`; a reference is `var(--name)` or `var(--name, fallback)` — followed
|
||||
* by `)` or `,`, never by `:`. So no anchor is needed, and adding one is
|
||||
* actively wrong: an earlier version required the match to follow `{` or `;`,
|
||||
* which silently dropped every declaration that came after a comment —
|
||||
* including `--color-bg`, the first and most-used token in the file.
|
||||
*/
|
||||
const DECLARATION = /(--[A-Za-z0-9_-]+)\s*:/g;
|
||||
|
||||
/** Comments are stripped first so a commented-out declaration isn't counted. */
|
||||
const COMMENT = /\/\*[\s\S]*?\*\//g;
|
||||
|
||||
/** The dark block's selector, as written in theme.css. */
|
||||
const DARK_SELECTOR = '[data-theme="dark"]';
|
||||
|
||||
const GROUP_PREFIXES: ReadonlyArray<[string, TokenGroup]> = [
|
||||
["--color-", "color"],
|
||||
["--radius-", "radius"],
|
||||
["--gradient-", "gradient"],
|
||||
["--glow-", "glow"],
|
||||
["--focus-", "focus"],
|
||||
["--page-", "layout"],
|
||||
["--sidebar-", "layout"],
|
||||
["--chat-", "layout"],
|
||||
];
|
||||
|
||||
export function groupFor(name: string): TokenGroup {
|
||||
for (const [prefix, group] of GROUP_PREFIXES) {
|
||||
if (name.startsWith(prefix)) return group;
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
/** Every custom property declared anywhere in the stylesheet, in source order, deduped. */
|
||||
export function tokenNames(css: string = themeCss): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const match of css.replace(COMMENT, "").matchAll(DECLARATION)) {
|
||||
const name = match[1];
|
||||
if (!seen.has(name)) {
|
||||
seen.add(name);
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The subset re-declared inside the dark block — i.e. tokens that change with mode. */
|
||||
export function darkOverriddenNames(css: string = themeCss): Set<string> {
|
||||
const bare = css.replace(COMMENT, "");
|
||||
const start = bare.indexOf(DARK_SELECTOR);
|
||||
if (start === -1) return new Set();
|
||||
const open = bare.indexOf("{", start);
|
||||
const close = bare.indexOf("}", open);
|
||||
if (open === -1 || close === -1) return new Set();
|
||||
return new Set(tokenNames(bare.slice(open, close)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resolved value of every token in `host`'s context.
|
||||
*
|
||||
* Pass a container to read the tokens as they apply INSIDE it — which is how
|
||||
* the preview surface reads a scoped override without disturbing the page.
|
||||
* Defaults to the document root, i.e. the app-wide values.
|
||||
*/
|
||||
export function readTokens(host: Element = document.documentElement): DesignToken[] {
|
||||
const computed = getComputedStyle(host);
|
||||
const dark = darkOverriddenNames();
|
||||
return tokenNames().map((name) => ({
|
||||
name,
|
||||
group: groupFor(name),
|
||||
value: computed.getPropertyValue(name).trim(),
|
||||
overriddenInDark: dark.has(name),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read tokens as they would resolve in a given mode, without touching the page.
|
||||
*
|
||||
* Uses an offscreen probe carrying the mode attribute, so the live UI is never
|
||||
* mutated to take a reading.
|
||||
*
|
||||
* KNOWN LIMITATION, and it is a property of the stylesheet rather than of this
|
||||
* function: light is declared on `:root` while dark is declared on
|
||||
* `[data-theme="dark"]`. An attribute selector can ADD the dark values to a
|
||||
* subtree, but there is no `[data-theme="light"]` block to add the light ones
|
||||
* back. So reading "light" from inside a dark page returns the dark values —
|
||||
* the probe has nothing to match.
|
||||
*
|
||||
* Concretely: dark-inside-light previews work, light-inside-dark previews do
|
||||
* not. Introducing a `[data-theme="light"]` block alongside the dark-first flip
|
||||
* (milestone #251 step 6) is what makes this symmetric, and until then callers
|
||||
* should treat a cross-mode read as best-effort.
|
||||
*/
|
||||
export function readTokensForMode(mode: ThemeMode): DesignToken[] {
|
||||
const probe = document.createElement("div");
|
||||
probe.setAttribute("data-theme", mode);
|
||||
probe.style.display = "none";
|
||||
document.body.appendChild(probe);
|
||||
try {
|
||||
return readTokens(probe);
|
||||
} finally {
|
||||
probe.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/** Tokens grouped by family, preserving source order within each group. */
|
||||
export function groupTokens(tokens: DesignToken[]): Map<TokenGroup, DesignToken[]> {
|
||||
const out = new Map<TokenGroup, DesignToken[]>();
|
||||
for (const token of tokens) {
|
||||
const bucket = out.get(token.group);
|
||||
if (bucket) bucket.push(token);
|
||||
else out.set(token.group, [token]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens declared in the stylesheet that nothing references with `var()`.
|
||||
*
|
||||
* Dead tokens are drift too: `--chat-reading-width` and
|
||||
* `--chat-context-sidebar-width` outlived the chat subsystem that was deleted
|
||||
* in the MCP-first pivot, and nothing has referenced them since. Takes the
|
||||
* corpus of source files to search as an argument so the caller decides what
|
||||
* "used" means — this module has no opinion about the project layout.
|
||||
*/
|
||||
export function unreferencedTokens(tokens: DesignToken[], sources: string[]): DesignToken[] {
|
||||
const haystack = sources.join("\n");
|
||||
return tokens.filter((token) => !haystack.includes(`var(${token.name}`));
|
||||
}
|
||||
@@ -1,763 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import FullCalendar from "@fullcalendar/vue3";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import type { CalendarOptions, EventClickArg, EventDropArg } from "@fullcalendar/core";
|
||||
import type { DateClickArg, EventResizeDoneArg } from "@fullcalendar/interaction";
|
||||
import { listEvents, updateEvent, type EventEntry } from "@/api/client";
|
||||
import EventSlideOver from "@/components/EventSlideOver.vue";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { fmtTime, fmtDateTime, fmtDayLabel } from "@/utils/dateFormat";
|
||||
import { MapPin } from "lucide-vue-next";
|
||||
|
||||
const toast = useToastStore();
|
||||
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
|
||||
|
||||
// Slide-over state
|
||||
const slideOverEvent = ref<EventEntry | null>(null);
|
||||
const slideOverOpen = ref(false);
|
||||
const slideOverDate = ref<string>("");
|
||||
|
||||
function openCreate(date: string) {
|
||||
slideOverEvent.value = null;
|
||||
slideOverDate.value = date;
|
||||
slideOverOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(event: EventEntry) {
|
||||
slideOverEvent.value = event;
|
||||
slideOverDate.value = "";
|
||||
slideOverOpen.value = true;
|
||||
}
|
||||
|
||||
function closeSlideOver() {
|
||||
slideOverOpen.value = false;
|
||||
}
|
||||
|
||||
// Event entry cache keyed by id
|
||||
const eventCache = new Map<number, EventEntry>();
|
||||
|
||||
function toFcEvent(e: EventEntry) {
|
||||
// For all-day events pass date-only strings so FullCalendar never shifts
|
||||
// the date through timezone conversion (UTC midnight → previous day in UTC-X).
|
||||
return {
|
||||
id: String(e.id),
|
||||
title: e.title,
|
||||
start: e.all_day ? e.start_dt.slice(0, 10) : e.start_dt,
|
||||
end: e.all_day ? (e.end_dt?.slice(0, 10) ?? undefined) : (e.end_dt ?? undefined),
|
||||
allDay: e.all_day,
|
||||
backgroundColor: e.color || undefined,
|
||||
borderColor: e.color || undefined,
|
||||
extendedProps: { entryId: e.id },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Upcoming events list ───────────────────────────────────────────────────
|
||||
const upcomingEvents = ref<EventEntry[]>([]);
|
||||
|
||||
async function loadUpcoming() {
|
||||
const now = new Date();
|
||||
const end = new Date(now.getTime() + 28 * 86_400_000); // 4 weeks
|
||||
try {
|
||||
const entries = await listEvents(now.toISOString(), end.toISOString());
|
||||
upcomingEvents.value = entries.sort(
|
||||
(a, b) => new Date(a.start_dt).getTime() - new Date(b.start_dt).getTime()
|
||||
);
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
onMounted(loadUpcoming);
|
||||
|
||||
// ── Month/year picker ──────────────────────────────────────────────────────
|
||||
const currentViewYear = ref(new Date().getFullYear());
|
||||
const currentViewMonth = ref(new Date().getMonth());
|
||||
const pickerOpen = ref(false);
|
||||
const pickerYear = ref(new Date().getFullYear());
|
||||
const pickerStyle = ref<Record<string, string>>({});
|
||||
const pickerEl = ref<HTMLElement | null>(null);
|
||||
|
||||
const MONTH_NAMES = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const;
|
||||
|
||||
function handleDatesSet(arg: { view: { currentStart: Date } }) {
|
||||
const d = arg.view.currentStart;
|
||||
currentViewYear.value = d.getFullYear();
|
||||
currentViewMonth.value = d.getMonth();
|
||||
}
|
||||
|
||||
function jumpTo(year: number, month: number) {
|
||||
calendarRef.value?.getApi().gotoDate(new Date(year, month, 1));
|
||||
pickerOpen.value = false;
|
||||
}
|
||||
|
||||
// ── Event popover ──────────────────────────────────────────────────────────
|
||||
const popover = ref<EventEntry | null>(null);
|
||||
const popoverStyle = ref<Record<string, string>>({});
|
||||
const popoverEl = ref<HTMLElement | null>(null);
|
||||
|
||||
function showPopover(entry: EventEntry, clickEvent: MouseEvent) {
|
||||
popover.value = entry;
|
||||
nextTickPositionPopover(clickEvent);
|
||||
}
|
||||
|
||||
function nextTickPositionPopover(clickEvent: MouseEvent) {
|
||||
// Position after DOM update
|
||||
requestAnimationFrame(() => {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const pw = 280;
|
||||
const ph = 220; // approximate
|
||||
let left = clickEvent.clientX + 8;
|
||||
let top = clickEvent.clientY + 8;
|
||||
if (left + pw > vw - 16) left = clickEvent.clientX - pw - 8;
|
||||
if (top + ph > vh - 16) top = clickEvent.clientY - ph - 8;
|
||||
popoverStyle.value = {
|
||||
position: "fixed",
|
||||
left: `${Math.max(8, left)}px`,
|
||||
top: `${Math.max(8, top)}px`,
|
||||
zIndex: "9999",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function closePopover() {
|
||||
popover.value = null;
|
||||
}
|
||||
|
||||
function onPopoverEdit() {
|
||||
if (popover.value) {
|
||||
openEdit(popover.value);
|
||||
closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
// Close popover / open picker on outside or title click
|
||||
function onDocClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
// Title click → toggle month/year picker
|
||||
const titleEl = target.closest(".fc-toolbar-title");
|
||||
if (titleEl) {
|
||||
if (!pickerOpen.value) {
|
||||
pickerYear.value = currentViewYear.value;
|
||||
const rect = titleEl.getBoundingClientRect();
|
||||
const left = Math.max(8, Math.min(rect.left + rect.width / 2 - 140, window.innerWidth - 296));
|
||||
pickerStyle.value = {
|
||||
position: "fixed",
|
||||
top: `${rect.bottom + 6}px`,
|
||||
left: `${left}px`,
|
||||
zIndex: "9999",
|
||||
};
|
||||
}
|
||||
pickerOpen.value = !pickerOpen.value;
|
||||
return;
|
||||
}
|
||||
// Close picker on outside click
|
||||
if (pickerOpen.value && pickerEl.value && !pickerEl.value.contains(target)) {
|
||||
pickerOpen.value = false;
|
||||
}
|
||||
// Close event popover on outside click
|
||||
if (popover.value && popoverEl.value && !popoverEl.value.contains(target)) {
|
||||
closePopover();
|
||||
}
|
||||
}
|
||||
|
||||
function onCalendarChanged() {
|
||||
calendarRef.value?.getApi().refetchEvents();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("mousedown", onDocClick);
|
||||
document.addEventListener("scribe:calendar-changed", onCalendarChanged);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("mousedown", onDocClick);
|
||||
document.removeEventListener("scribe:calendar-changed", onCalendarChanged);
|
||||
});
|
||||
|
||||
// ── Calendar callbacks ─────────────────────────────────────────────────────
|
||||
async function loadEvents(
|
||||
fetchInfo: { startStr: string; endStr: string },
|
||||
successCallback: (events: object[]) => void,
|
||||
failureCallback: (error: Error) => void,
|
||||
) {
|
||||
try {
|
||||
const entries = await listEvents(fetchInfo.startStr, fetchInfo.endStr);
|
||||
eventCache.clear();
|
||||
for (const e of entries) eventCache.set(e.id, e);
|
||||
successCallback(entries.map(toFcEvent));
|
||||
loadUpcoming();
|
||||
} catch (err) {
|
||||
failureCallback(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDateClick(arg: DateClickArg) {
|
||||
closePopover();
|
||||
openCreate(arg.dateStr);
|
||||
}
|
||||
|
||||
function handleEventClick(arg: EventClickArg) {
|
||||
const id = arg.event.extendedProps.entryId as number;
|
||||
const entry = eventCache.get(id);
|
||||
if (entry) showPopover(entry, arg.jsEvent as MouseEvent);
|
||||
}
|
||||
|
||||
async function handleEventDrop(arg: EventDropArg) {
|
||||
const id = arg.event.extendedProps.entryId as number;
|
||||
const start_dt = arg.event.startStr;
|
||||
const end_dt = arg.event.endStr || undefined;
|
||||
try {
|
||||
const updated = await updateEvent(id, { start_dt, end_dt, all_day: arg.event.allDay });
|
||||
eventCache.set(id, updated);
|
||||
loadUpcoming();
|
||||
} catch {
|
||||
arg.revert();
|
||||
toast.show("Failed to move event", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEventResize(arg: EventResizeDoneArg) {
|
||||
const id = arg.event.extendedProps.entryId as number;
|
||||
const start_dt = arg.event.startStr;
|
||||
const end_dt = arg.event.endStr || undefined;
|
||||
try {
|
||||
const updated = await updateEvent(id, { start_dt, end_dt });
|
||||
eventCache.set(id, updated);
|
||||
loadUpcoming();
|
||||
} catch {
|
||||
arg.revert();
|
||||
toast.show("Failed to resize event", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function onCreated(entry: EventEntry) {
|
||||
eventCache.set(entry.id, entry);
|
||||
calendarRef.value?.getApi().addEvent(toFcEvent(entry));
|
||||
closeSlideOver();
|
||||
loadUpcoming();
|
||||
}
|
||||
|
||||
function onUpdated(entry: EventEntry) {
|
||||
eventCache.set(entry.id, entry);
|
||||
const api = calendarRef.value?.getApi();
|
||||
if (api) {
|
||||
const existing = api.getEventById(String(entry.id));
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
api.addEvent(toFcEvent(entry));
|
||||
}
|
||||
}
|
||||
closeSlideOver();
|
||||
loadUpcoming();
|
||||
}
|
||||
|
||||
function onDeleted(id: number) {
|
||||
eventCache.delete(id);
|
||||
const api = calendarRef.value?.getApi();
|
||||
if (api) api.getEventById(String(id))?.remove();
|
||||
closeSlideOver();
|
||||
upcomingEvents.value = upcomingEvents.value.filter((e) => e.id !== id);
|
||||
}
|
||||
|
||||
const calendarOptions: CalendarOptions = {
|
||||
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
|
||||
initialView: "dayGridMonth",
|
||||
timeZone: "local",
|
||||
editable: true,
|
||||
selectable: false,
|
||||
headerToolbar: {
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right: "dayGridMonth,timeGridWeek,timeGridDay",
|
||||
},
|
||||
events: loadEvents,
|
||||
datesSet: handleDatesSet,
|
||||
dateClick: handleDateClick,
|
||||
eventClick: handleEventClick,
|
||||
eventDrop: handleEventDrop,
|
||||
eventResize: handleEventResize,
|
||||
height: "auto",
|
||||
};
|
||||
|
||||
// Group upcoming events by day label
|
||||
const upcomingGrouped = computed(() => {
|
||||
const groups: { label: string; date: string; events: EventEntry[] }[] = [];
|
||||
for (const e of upcomingEvents.value) {
|
||||
const label = fmtDayLabel(e.start_dt);
|
||||
const existing = groups.find((g) => g.label === label);
|
||||
if (existing) {
|
||||
existing.events.push(e);
|
||||
} else {
|
||||
groups.push({ label, date: e.start_dt, events: [e] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="calendar-view">
|
||||
<div class="cal-header">
|
||||
<h1 class="cal-title">Calendar</h1>
|
||||
<button class="btn-new-event" @click="openCreate(new Date().toISOString().slice(0, 10))">
|
||||
+ New Event
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="fc-wrapper">
|
||||
<FullCalendar ref="calendarRef" :options="calendarOptions" />
|
||||
</div>
|
||||
|
||||
<!-- ── Upcoming events strip ───────────────────────────────────────── -->
|
||||
<div v-if="upcomingEvents.length" class="upcoming-section">
|
||||
<h2 class="upcoming-title">Upcoming</h2>
|
||||
<div class="upcoming-groups">
|
||||
<div v-for="group in upcomingGrouped" :key="group.label" class="upcoming-group">
|
||||
<div class="upcoming-day-label">{{ group.label }}</div>
|
||||
<div class="upcoming-cards">
|
||||
<div
|
||||
v-for="ev in group.events"
|
||||
:key="ev.id"
|
||||
class="upcoming-card"
|
||||
:style="ev.color ? { '--ev-color': ev.color } : {}"
|
||||
@click="openEdit(ev)"
|
||||
>
|
||||
<div class="upcoming-card-accent"></div>
|
||||
<div class="upcoming-card-body">
|
||||
<div class="upcoming-card-title">{{ ev.title }}</div>
|
||||
<div class="upcoming-card-time">
|
||||
<template v-if="ev.all_day">All day</template>
|
||||
<template v-else>
|
||||
{{ fmtTime(ev.start_dt) }}
|
||||
<span v-if="ev.end_dt"> – {{ fmtTime(ev.end_dt) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="ev.location" class="upcoming-card-meta">
|
||||
<MapPin :size="16" style="flex-shrink:0" />
|
||||
{{ ev.location }}
|
||||
</div>
|
||||
<div v-if="ev.description" class="upcoming-card-desc">{{ ev.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Event popover ───────────────────────────────────────────────── -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="popover"
|
||||
ref="popoverEl"
|
||||
class="event-popover"
|
||||
:style="popoverStyle"
|
||||
>
|
||||
<div class="popover-accent" :style="popover.color ? { background: popover.color } : {}"></div>
|
||||
<div class="popover-content">
|
||||
<div class="popover-title">{{ popover.title }}</div>
|
||||
<div class="popover-time">
|
||||
<template v-if="popover.all_day">
|
||||
{{ fmtDateTime(popover.start_dt, true) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ fmtDateTime(popover.start_dt, false) }}
|
||||
<span v-if="popover.end_dt"> – {{ fmtTime(popover.end_dt) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="popover.location" class="popover-meta">
|
||||
<MapPin :size="16" style="flex-shrink:0;margin-top:1px" />
|
||||
{{ popover.location }}
|
||||
</div>
|
||||
<div v-if="popover.description" class="popover-desc">{{ popover.description }}</div>
|
||||
<div class="popover-actions">
|
||||
<button class="popover-btn popover-btn--edit" @click="onPopoverEdit">Edit</button>
|
||||
<button class="popover-btn popover-btn--close" @click="closePopover">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- ── Month/year picker ──────────────────────────────────────────── -->
|
||||
<Teleport to="body">
|
||||
<div v-if="pickerOpen" ref="pickerEl" class="month-picker" :style="pickerStyle">
|
||||
<div class="picker-year-row">
|
||||
<button class="picker-year-btn" @click="pickerYear--" aria-label="Previous year">‹</button>
|
||||
<span class="picker-year-label">{{ pickerYear }}</span>
|
||||
<button class="picker-year-btn" @click="pickerYear++" aria-label="Next year">›</button>
|
||||
</div>
|
||||
<div class="picker-months">
|
||||
<button
|
||||
v-for="(name, i) in MONTH_NAMES"
|
||||
:key="i"
|
||||
class="picker-month"
|
||||
:class="{ active: pickerYear === currentViewYear && i === currentViewMonth }"
|
||||
@click="jumpTo(pickerYear, i)"
|
||||
>{{ name }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<EventSlideOver
|
||||
v-if="slideOverOpen"
|
||||
:event="slideOverEvent"
|
||||
:initial-date="slideOverDate"
|
||||
@close="closeSlideOver"
|
||||
@created="onCreated"
|
||||
@updated="onUpdated"
|
||||
@deleted="onDeleted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.calendar-view {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 1.5rem 3rem;
|
||||
}
|
||||
|
||||
.cal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.cal-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
/* New event: Moss action-primary — workflow action, not a brand moment */
|
||||
.btn-new-event {
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 1.1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-new-event:hover { background: var(--color-action-primary-hover); }
|
||||
|
||||
.fc-wrapper {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: var(--radius-lg, 18px);
|
||||
padding: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* FullCalendar dark theme overrides */
|
||||
:deep(.fc) {
|
||||
color: var(--color-text, #e8e9f0);
|
||||
font-family: inherit;
|
||||
}
|
||||
:deep(.fc-toolbar-title) {
|
||||
/* FullCalendar renders this as <h2>, which would otherwise pick up the
|
||||
theme.css h1/h2 → Fraunces rule. At 1.1rem it's below the doc's
|
||||
"Fraunces only at ≥18px" threshold, so explicit Inter. */
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
transition: background 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
:deep(.fc-toolbar-title:hover) {
|
||||
background: rgba(255,255,255,0.07);
|
||||
}
|
||||
:deep(.fc-button) {
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 0.82rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
:deep(.fc-button:hover),
|
||||
:deep(.fc-button-active) {
|
||||
background: var(--color-primary) !important;
|
||||
border-color: var(--color-primary) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
:deep(.fc-button:focus) { box-shadow: none !important; }
|
||||
:deep(.fc-daygrid-day-number),
|
||||
:deep(.fc-col-header-cell-cushion) {
|
||||
color: var(--color-text-muted, #888);
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
:deep(.fc-daygrid-day.fc-day-today) {
|
||||
background: var(--color-primary-tint);
|
||||
}
|
||||
:deep(.fc-event) {
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
padding: 1px 4px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
:deep(.fc-event-main) { color: #fff; }
|
||||
:deep(.fc-event:not([style*="background"])) {
|
||||
background: var(--color-primary);
|
||||
}
|
||||
:deep(.fc-daygrid-day-frame) { min-height: 5rem; }
|
||||
:deep(.fc-scrollgrid) { border-color: var(--color-border, #2a2b30); }
|
||||
:deep(.fc-scrollgrid td),
|
||||
:deep(.fc-scrollgrid th) { border-color: var(--color-border, #2a2b30); }
|
||||
:deep(.fc-daygrid-day) { cursor: pointer; }
|
||||
:deep(.fc-daygrid-day:hover) { background: rgba(255,255,255,0.03); }
|
||||
|
||||
/* ── Month/year picker ──────────────────────────────────────────────────── */
|
||||
.month-picker {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
width: 280px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.picker-year-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.picker-year-label {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
}
|
||||
|
||||
.picker-year-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-muted, #888);
|
||||
cursor: pointer;
|
||||
padding: 2px 10px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.picker-year-btn:hover {
|
||||
color: var(--color-text, #e8e9f0);
|
||||
border-color: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
.picker-months {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.picker-month {
|
||||
padding: 7px 4px;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
cursor: pointer;
|
||||
font-size: 0.84rem;
|
||||
text-align: center;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
.picker-month:hover {
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
.picker-month.active {
|
||||
background: rgba(91, 74, 138,0.22);
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Upcoming strip ─────────────────────────────────────────────────────── */
|
||||
.upcoming-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.upcoming-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 0.78rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.upcoming-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.upcoming-day-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.upcoming-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.upcoming-card {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.upcoming-card:hover {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.upcoming-card-accent {
|
||||
width: 4px;
|
||||
flex-shrink: 0;
|
||||
background: var(--ev-color, #5B4A8A);
|
||||
}
|
||||
|
||||
.upcoming-card-body {
|
||||
padding: 0.6rem 0.85rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.upcoming-card-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.upcoming-card-time {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.upcoming-card-meta {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.upcoming-card-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
margin-top: 0.3rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Event popover ──────────────────────────────────────────────────────── */
|
||||
.event-popover {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
|
||||
width: 280px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.popover-accent {
|
||||
height: 4px;
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.popover-content {
|
||||
padding: 0.85rem 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.popover-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #e8e9f0);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.popover-time {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.popover-meta {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.popover-desc {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary, #aaa);
|
||||
line-height: 1.45;
|
||||
margin-bottom: 0.6rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.popover-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border, #2a2b30);
|
||||
}
|
||||
|
||||
.popover-btn {
|
||||
flex: 1;
|
||||
padding: 0.35rem 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.popover-btn:hover { opacity: 0.85; }
|
||||
.popover-btn--edit {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.popover-btn--close {
|
||||
background: var(--color-input-bg, var(--color-bg));
|
||||
color: var(--color-text-muted, #888);
|
||||
border: 1px solid var(--color-border, #2a2b30);
|
||||
}
|
||||
</style>
|
||||
@@ -7,39 +7,33 @@ interface TaskRow { id: number; title: string; status: string; priority: string
|
||||
interface MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
|
||||
interface ActiveProject {
|
||||
id: number; title: string; color: string | null; last_activity: string;
|
||||
open_count: number; progress_pct: number;
|
||||
open_count: number; done_count: number; progress_pct: number;
|
||||
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
|
||||
}
|
||||
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
|
||||
interface UpcomingEvent { id: number; title: string; start_dt: string | null; all_day: boolean }
|
||||
interface WeekStats { completed_this_week: number; open_total: number; in_progress: number; active_plans: number }
|
||||
interface IssueRow { id: number; title: string; status: string; priority: string; project_id: number | null; project_title: string | null }
|
||||
interface DashboardData {
|
||||
active_projects: ActiveProject[];
|
||||
recently_completed: DoneItem[];
|
||||
upcoming_events: UpcomingEvent[];
|
||||
open_issues: IssueRow[];
|
||||
week_stats: WeekStats;
|
||||
}
|
||||
|
||||
const data = ref<DashboardData | null>(null);
|
||||
const loading = ref(true);
|
||||
const showAllDone = ref(false);
|
||||
const DONE_VISIBLE = 5;
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
data.value = await apiGet<DashboardData>("/api/dashboard");
|
||||
} catch {
|
||||
data.value = { active_projects: [], recently_completed: [], upcoming_events: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
|
||||
data.value = { active_projects: [], recently_completed: [], open_issues: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function fmtEvent(e: UpcomingEvent): string {
|
||||
if (!e.start_dt) return "";
|
||||
const d = new Date(e.start_dt);
|
||||
const day = d.toLocaleDateString(undefined, { weekday: "short" });
|
||||
if (e.all_day) return day;
|
||||
return `${day} ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -53,17 +47,25 @@ function fmtEvent(e: UpcomingEvent): string {
|
||||
|
||||
<template v-else-if="data">
|
||||
<!-- Done recently -->
|
||||
<section v-if="data.recently_completed.length" class="done-strip">
|
||||
<span class="dash-label">✓ Done recently</span>
|
||||
<section v-if="data.recently_completed.length" class="done-recent">
|
||||
<div class="dash-label">✓ Done recently</div>
|
||||
<router-link
|
||||
v-for="d in data.recently_completed"
|
||||
v-for="d in (showAllDone ? data.recently_completed : data.recently_completed.slice(0, DONE_VISIBLE))"
|
||||
:key="d.id"
|
||||
:to="`/tasks/${d.id}`"
|
||||
class="done-chip"
|
||||
class="done-row"
|
||||
>
|
||||
{{ d.title }}
|
||||
<span class="done-mark">○</span>
|
||||
<span class="done-title">{{ d.title }}</span>
|
||||
<span class="done-meta">{{ d.project_title || "—" }} · {{ relativeTime(d.completed_at) }}</span>
|
||||
</router-link>
|
||||
<button
|
||||
v-if="data.recently_completed.length > DONE_VISIBLE"
|
||||
class="done-more"
|
||||
@click="showAllDone = !showAllDone"
|
||||
>
|
||||
{{ showAllDone ? "Show less" : `… ${data.recently_completed.length - DONE_VISIBLE} more` }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="dash-cols">
|
||||
@@ -122,16 +124,21 @@ function fmtEvent(e: UpcomingEvent): string {
|
||||
|
||||
<!-- Right rail -->
|
||||
<aside class="dash-rail">
|
||||
<div class="dash-label">Upcoming · 7 days</div>
|
||||
<div class="rail-card">
|
||||
<template v-if="data.upcoming_events.length">
|
||||
<div v-for="e in data.upcoming_events" :key="e.id" class="evt-row">
|
||||
<span class="evt-when">{{ fmtEvent(e) }}</span>
|
||||
<span class="evt-title">{{ e.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="rail-empty">Nothing scheduled.</p>
|
||||
</div>
|
||||
<template v-if="data.open_issues.length">
|
||||
<div class="dash-label">⚠ Open issues</div>
|
||||
<div class="rail-card issues-card">
|
||||
<router-link
|
||||
v-for="i in data.open_issues"
|
||||
:key="i.id"
|
||||
:to="`/tasks/${i.id}`"
|
||||
class="issue-row"
|
||||
>
|
||||
<span class="issue-mark" :class="`imk-${i.status}`">{{ i.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||
<span class="issue-title">{{ i.title }}</span>
|
||||
<span class="issue-meta">{{ i.project_title || '—' }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="dash-label">This week</div>
|
||||
<div class="rail-card stats">
|
||||
@@ -140,6 +147,22 @@ function fmtEvent(e: UpcomingEvent): string {
|
||||
<span class="stats-sub">{{ data.week_stats.in_progress }} in progress · {{ data.week_stats.active_plans }} plans</span>
|
||||
</div>
|
||||
|
||||
<template v-if="data.active_projects.length">
|
||||
<div class="dash-label">Projects</div>
|
||||
<div class="rail-card proj-stats">
|
||||
<router-link
|
||||
v-for="p in data.active_projects"
|
||||
:key="p.id"
|
||||
:to="`/projects/${p.id}`"
|
||||
class="pstat-row"
|
||||
>
|
||||
<span class="pstat-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
|
||||
<span class="pstat-title">{{ p.title }}</span>
|
||||
<span class="pstat-counts">{{ p.open_count }} open · {{ p.done_count }} done</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="quick-add">
|
||||
<router-link to="/tasks/new" class="qa-btn">+ Task</router-link>
|
||||
<router-link to="/notes/new" class="qa-btn">+ Note</router-link>
|
||||
@@ -159,10 +182,14 @@ function fmtEvent(e: UpcomingEvent): string {
|
||||
.dash-empty { color: var(--color-muted); padding: 1rem 0; }
|
||||
.dash-empty.card { padding: 1rem; border: 1px dashed var(--color-border); border-radius: 10px; }
|
||||
|
||||
.done-strip { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 1.5rem; }
|
||||
.done-chip { display: inline-flex; flex-direction: column; gap: 1px; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 14px; padding: 4px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
||||
.done-chip:hover { border-color: var(--color-primary); }
|
||||
.done-meta { font-size: 0.7rem; color: var(--color-muted); }
|
||||
.done-recent { margin-bottom: 1.5rem; }
|
||||
.done-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.86rem; }
|
||||
.done-row:hover { background: var(--color-hover); }
|
||||
.done-mark { color: var(--color-primary); flex-shrink: 0; }
|
||||
.done-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.done-meta { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
|
||||
.done-more { background: none; border: none; cursor: pointer; margin-top: 0.25rem; padding: 0.2rem 0.55rem; font-size: 0.78rem; color: var(--color-primary); font-family: inherit; }
|
||||
.done-more:hover { text-decoration: underline; }
|
||||
|
||||
.dash-cols { display: flex; gap: 1.25rem; align-items: flex-start; }
|
||||
.dash-main { flex: 1.7; min-width: 0; }
|
||||
@@ -193,16 +220,25 @@ function fmtEvent(e: UpcomingEvent): string {
|
||||
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
|
||||
|
||||
.rail-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
|
||||
.evt-row { display: flex; gap: 0.6rem; padding: 0.3rem 0; border-bottom: 1px solid var(--color-border); font-size: 0.85rem; }
|
||||
.evt-row:last-child { border-bottom: none; }
|
||||
.evt-when { color: var(--color-muted); white-space: nowrap; }
|
||||
.evt-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rail-empty { margin: 0; color: var(--color-muted); font-size: 0.85rem; }
|
||||
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
|
||||
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
|
||||
.proj-stats { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
|
||||
.pstat-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
|
||||
.pstat-row:hover { background: var(--color-hover); }
|
||||
.pstat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.pstat-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
|
||||
.pstat-counts { font-size: 0.74rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
|
||||
.quick-add { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
.qa-btn { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--color-text); text-decoration: none; }
|
||||
.qa-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
.issues-card { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
|
||||
.issue-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.85rem; }
|
||||
.issue-row:hover { background: var(--color-hover); }
|
||||
.issue-mark { color: var(--color-muted); flex-shrink: 0; }
|
||||
.issue-mark.imk-in_progress { color: var(--color-primary); }
|
||||
.issue-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.issue-meta { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; flex-shrink: 0; }
|
||||
|
||||
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,545 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Design explorer — the gallery (milestone #251 step 3).
|
||||
*
|
||||
* Renders the design system against the tokens that are actually live, read at
|
||||
* runtime rather than parsed from source, so what you see here is what the app
|
||||
* is using right now.
|
||||
*
|
||||
* HONESTY RULE, and the reason parts of this page say "not implemented":
|
||||
* a gallery of hand-written look-alikes drifts from the app within a month and
|
||||
* then lies — which is the same failure this whole surface exists to catch. So
|
||||
* every specimen below is either a REAL component imported from the app, or a
|
||||
* real token read from the browser, or it is explicitly marked as missing.
|
||||
*
|
||||
* Buttons WERE the case where that bit: `.btn-primary` was defined five times
|
||||
* in five `<style scoped>` blocks, all five drifted, and this page reported it
|
||||
* as a gap because drawing a look-alike would have made it a sixth copy.
|
||||
* `assets/components.css` is now the single definition (#2273), so the
|
||||
* specimens below are the app's real classes — they cannot drift from the app
|
||||
* without drifting the app itself.
|
||||
*/
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
import { fetchDesignExpectations } from "@/api/design";
|
||||
import DesignTabs from "@/components/DesignTabs.vue";
|
||||
import PriorityBadge from "@/components/PriorityBadge.vue";
|
||||
import StatusBadge from "@/components/StatusBadge.vue";
|
||||
import TagPill from "@/components/TagPill.vue";
|
||||
import {
|
||||
compareToTokens,
|
||||
rankFindings,
|
||||
summarise,
|
||||
type Expectation,
|
||||
type Finding,
|
||||
} from "@/utils/designDrift";
|
||||
import { groupTokens, readTokens, type DesignToken, type TokenGroup } from "@/utils/designTokens";
|
||||
|
||||
const tokens = ref<DesignToken[]>([]);
|
||||
const expectations = ref<Expectation[]>([]);
|
||||
const designRulebookId = ref<number | null>(null);
|
||||
const driftLoaded = ref(false);
|
||||
const showCleanRows = ref(false);
|
||||
|
||||
/**
|
||||
* Read on mount, not at module scope: the values depend on the live cascade,
|
||||
* which needs the app's stylesheets applied and the theme attribute set.
|
||||
*/
|
||||
onMounted(async () => {
|
||||
tokens.value = readTokens();
|
||||
try {
|
||||
const response = await fetchDesignExpectations();
|
||||
designRulebookId.value = response.rulebook_id;
|
||||
expectations.value = response.expectations;
|
||||
} catch {
|
||||
// The gallery is useful without the panel, so a failed fetch degrades to
|
||||
// "no drift data" rather than taking the page down with it.
|
||||
designRulebookId.value = null;
|
||||
} finally {
|
||||
driftLoaded.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
const findings = computed<Finding[]>(() =>
|
||||
rankFindings(compareToTokens(expectations.value, tokens.value)),
|
||||
);
|
||||
const driftSummary = computed(() => summarise(findings.value));
|
||||
const visibleFindings = computed(() =>
|
||||
showCleanRows.value ? findings.value : findings.value.filter((f) => f.status !== "ok"),
|
||||
);
|
||||
|
||||
const grouped = computed(() => groupTokens(tokens.value));
|
||||
|
||||
const GROUP_ORDER: TokenGroup[] = ["color", "radius", "glow", "gradient", "focus", "layout", "other"];
|
||||
const orderedGroups = computed(() =>
|
||||
GROUP_ORDER.filter((g) => grouped.value.has(g)).map((g) => ({ group: g, tokens: grouped.value.get(g)! })),
|
||||
);
|
||||
|
||||
/** A token whose value reads as a colour is worth showing as a swatch. */
|
||||
function isColourish(value: string): boolean {
|
||||
return /^(#|rgba?\(|hsla?\(|color-mix\()/.test(value.trim());
|
||||
}
|
||||
|
||||
/** Rule 65's four variants — none of which exists as a shared artifact (#2273). */
|
||||
const RULEBOOK_BUTTONS = [
|
||||
{ name: "Primary", spec: "Moss #4A5D3F bg, Parchment text, no border" },
|
||||
{ name: "Secondary", spec: "Bronze #8B7355 bg, Parchment text, no border" },
|
||||
{ name: "Ghost", spec: "transparent, Parchment text, 0.5px Pewter border" },
|
||||
{ name: "Destructive", spec: "Oxblood #6B2118 bg, Parchment text, pair with icon" },
|
||||
];
|
||||
|
||||
const TYPE_SPECIMENS = [
|
||||
{ token: "Display", spec: "40 / 500 / Fraunces" },
|
||||
{ token: "H1", spec: "32 / 500 / Fraunces" },
|
||||
{ token: "H2", spec: "24 / 500 / Fraunces" },
|
||||
{ token: "H3", spec: "18 / 500 / Inter" },
|
||||
{ token: "Body", spec: "15 / 400 / Inter" },
|
||||
{ token: "Body small", spec: "13 / 400 / Inter" },
|
||||
{ token: "Label", spec: "12 / 500 / Inter" },
|
||||
{ token: "Code", spec: "13 / 400 / JetBrains Mono" },
|
||||
{ token: "Tiny", spec: "11 / 500 / Inter, uppercase +0.08em" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="design-view">
|
||||
<DesignTabs />
|
||||
|
||||
<header class="design-header">
|
||||
<h1>Live tokens</h1>
|
||||
<p class="lede">
|
||||
The system as it actually is. Token values are read from the browser at
|
||||
runtime, so this page reflects the live cascade rather than what the
|
||||
stylesheet says. Components shown are the real ones — where a piece of
|
||||
the system has no shared implementation, it is marked missing rather
|
||||
than mocked up.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Drift: what the rulebook claims vs what the tokens do. -->
|
||||
<section class="design-section">
|
||||
<h2>Rulebook drift</h2>
|
||||
|
||||
<p v-if="!driftLoaded" class="muted">Checking against the design rulebook…</p>
|
||||
|
||||
<div v-else-if="designRulebookId === null" class="gap-notice">
|
||||
<strong>No design rulebook designated.</strong>
|
||||
<p>
|
||||
This install hasn't said which rulebook describes its design system, so
|
||||
there is nothing to check the tokens against. Designate one in
|
||||
<router-link to="/settings">Settings</router-link> and this panel will
|
||||
compare every colour and token the rulebook names against what the
|
||||
stylesheet actually resolves to.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<p class="section-note">
|
||||
<strong>{{ driftSummary.violated }}</strong> violated ·
|
||||
<strong>{{ driftSummary.missing }}</strong> missing ·
|
||||
{{ driftSummary.ok }} matching, from {{ driftSummary.total }} checkable
|
||||
claims in rulebook #{{ designRulebookId }}.
|
||||
</p>
|
||||
|
||||
<div class="gap-notice">
|
||||
<strong>This compares the rulebook against the TOKENS only.</strong>
|
||||
<p>
|
||||
A value hardcoded in a component — where a token should have been
|
||||
referenced — is invisible here, because the drift isn't in the tokens
|
||||
at all. Reading it would mean bundling every component's source into
|
||||
the app. That check belongs in CI and is tracked separately, so treat
|
||||
a clean panel as "the tokens agree", not "the app agrees".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="!findings.length" class="muted">
|
||||
The rulebook names nothing this panel can check. Rules that state values
|
||||
— colours, token names — produce claims; rules that state judgement
|
||||
don't, by design.
|
||||
</p>
|
||||
|
||||
<ul v-else class="spec-list">
|
||||
<li v-for="finding in visibleFindings" :key="`${finding.expectation.kind}:${finding.expectation.value}`">
|
||||
<span class="spec-name">
|
||||
<span
|
||||
v-if="finding.expectation.kind !== 'token'"
|
||||
class="swatch"
|
||||
:style="{ background: finding.expectation.value }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<code>{{ finding.expectation.value }}</code>
|
||||
</span>
|
||||
<span class="spec-detail">
|
||||
rule #{{ finding.expectation.rule_id }} — {{ finding.expectation.rule_title }}
|
||||
<span v-if="finding.matches.length" class="matches">
|
||||
· {{ finding.matches.join(", ") }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="spec-status" :class="finding.status">
|
||||
{{ finding.status === "violated" ? "forbidden, but present"
|
||||
: finding.status === "missing" ? "not in the stylesheet" : "ok" }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
v-if="findings.length && driftSummary.ok"
|
||||
class="reveal-toggle"
|
||||
@click="showCleanRows = !showCleanRows"
|
||||
>
|
||||
{{ showCleanRows ? "Hide" : "Show" }} the {{ driftSummary.ok }} matching claims
|
||||
</button>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- Real components: these are imported, not recreated. -->
|
||||
<section class="design-section">
|
||||
<h2>Components</h2>
|
||||
<p class="section-note">Imported from the app. What you see is what ships.</p>
|
||||
|
||||
<div class="specimen">
|
||||
<span class="specimen-label">Status badge</span>
|
||||
<div class="specimen-row">
|
||||
<StatusBadge status="todo" />
|
||||
<StatusBadge status="in_progress" />
|
||||
<StatusBadge status="done" />
|
||||
<StatusBadge status="cancelled" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="specimen">
|
||||
<span class="specimen-label">Priority badge</span>
|
||||
<div class="specimen-row">
|
||||
<PriorityBadge priority="low" />
|
||||
<PriorityBadge priority="medium" />
|
||||
<PriorityBadge priority="high" />
|
||||
<span class="muted">(<code>none</code> renders nothing, by design)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="specimen">
|
||||
<span class="specimen-label">Tag pill</span>
|
||||
<div class="specimen-row">
|
||||
<TagPill tag="design-system" />
|
||||
<TagPill tag="dismissible" dismissible />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- No longer a gap: these are the app's real classes, from the shared
|
||||
sheet. Nothing here is a look-alike — change components.css and these
|
||||
specimens change with it, which is the only way this page stays true. -->
|
||||
<section class="design-section">
|
||||
<h2>Buttons</h2>
|
||||
<div class="button-specimens">
|
||||
<button class="btn-primary">Save</button>
|
||||
<button class="btn-secondary">Detect</button>
|
||||
<button class="btn-ghost">Cancel</button>
|
||||
<button class="btn-danger">Delete</button>
|
||||
<button class="btn-primary" disabled>Disabled</button>
|
||||
</div>
|
||||
<p class="spec-caption">
|
||||
Three sizes, because the app has three kinds of button: a page action, a
|
||||
row action, and an affordance that sits inside a card without disturbing
|
||||
its rhythm.
|
||||
</p>
|
||||
<div class="button-specimens">
|
||||
<button class="btn-primary">Default — page action</button>
|
||||
<button class="btn-primary btn-compact">Compact — row action</button>
|
||||
<button class="btn-primary btn-inline">Inline</button>
|
||||
</div>
|
||||
<ul class="spec-list">
|
||||
<li v-for="b in RULEBOOK_BUTTONS" :key="b.name">
|
||||
<span class="spec-name">{{ b.name }}</span>
|
||||
<span class="spec-detail">{{ b.spec }}</span>
|
||||
<span class="spec-status ok">shared</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Typography: the families load, the scale does not exist as tokens. -->
|
||||
<section class="design-section">
|
||||
<h2>Type scale</h2>
|
||||
<div class="gap-notice">
|
||||
<strong>Families load; the scale has no tokens.</strong>
|
||||
<p>
|
||||
Fraunces, Inter and JetBrains Mono are imported (rule 59), but rule 60's
|
||||
scale is not expressed as custom properties, so sizes and weights are
|
||||
set ad hoc per component. Listed here as specification, not as a live
|
||||
specimen — there is nothing to read.
|
||||
</p>
|
||||
</div>
|
||||
<ul class="spec-list">
|
||||
<li v-for="t in TYPE_SPECIMENS" :key="t.token">
|
||||
<span class="spec-name">{{ t.token }}</span>
|
||||
<span class="spec-detail">{{ t.spec }}</span>
|
||||
<span class="spec-status missing">no token</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Tokens: entirely real, read live. -->
|
||||
<section v-for="{ group, tokens: groupTokenList } in orderedGroups" :key="group" class="design-section">
|
||||
<h2 class="token-group-heading">{{ group }} <span class="count">{{ groupTokenList.length }}</span></h2>
|
||||
<ul class="token-list">
|
||||
<li v-for="token in groupTokenList" :key="token.name" class="token-row">
|
||||
<span
|
||||
v-if="isColourish(token.value)"
|
||||
class="swatch"
|
||||
:style="{ background: token.value }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-else class="swatch swatch-none" aria-hidden="true" />
|
||||
<code class="token-name">{{ token.name }}</code>
|
||||
<code class="token-value">{{ token.value || "—" }}</code>
|
||||
<span v-if="token.overriddenInDark" class="token-flag" title="Re-declared in the dark block">
|
||||
mode-aware
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<p v-if="!tokens.length" class="muted">Reading tokens…</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.design-view {
|
||||
max-width: var(--page-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem var(--page-padding-x) 4rem;
|
||||
}
|
||||
|
||||
.design-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.lede {
|
||||
color: var(--color-text-secondary);
|
||||
max-width: 60ch;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.design-section {
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.design-section h2 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.token-group-heading {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.section-note,
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Specimens -------------------------------------------------------------- */
|
||||
|
||||
.specimen {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.specimen-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.specimen-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Gaps ------------------------------------------------------------------- */
|
||||
|
||||
.spec-caption {
|
||||
margin: 0 0 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
/* Layout only. The buttons inside style themselves from the shared sheet —
|
||||
adding any appearance rule here would recreate the copy this section
|
||||
just stopped being. */
|
||||
.button-specimens {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--fs-space-3);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gap-notice {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--color-warning);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.gap-notice p {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
max-width: 70ch;
|
||||
}
|
||||
|
||||
.spec-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.spec-list li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.spec-name {
|
||||
min-width: 8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.spec-detail {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.spec-status {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.spec-status.missing {
|
||||
background: var(--color-priority-medium-bg);
|
||||
color: var(--color-priority-medium);
|
||||
}
|
||||
|
||||
.spec-status.violated {
|
||||
background: var(--color-priority-high-bg);
|
||||
color: var(--color-priority-high);
|
||||
}
|
||||
|
||||
.spec-status.ok {
|
||||
background: var(--color-status-done-bg);
|
||||
color: var(--color-status-done);
|
||||
}
|
||||
|
||||
.spec-name .swatch {
|
||||
vertical-align: middle;
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
.matches {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.reveal-toggle {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.reveal-toggle:hover {
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Tokens ----------------------------------------------------------------- */
|
||||
|
||||
.token-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.token-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.swatch-none {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 3px,
|
||||
var(--color-border) 3px,
|
||||
var(--color-border) 4px
|
||||
);
|
||||
}
|
||||
|
||||
.token-name {
|
||||
min-width: 16rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.token-value {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.8rem;
|
||||
flex: 1;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.token-flag {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.05rem 0.35rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.token-name {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -49,7 +49,7 @@ async function handleSubmit() {
|
||||
/>
|
||||
</div>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<button type="submit" class="btn-submit" :disabled="submitting">
|
||||
<button type="submit" class="btn-primary btn-block" :disabled="submitting">
|
||||
{{ submitting ? "Sending..." : "Send Reset Link" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -137,24 +137,6 @@ async function handleSubmit() {
|
||||
.success-msg p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -600,7 +600,7 @@ onUnmounted(() => {
|
||||
.graph-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--header-height, 52px));
|
||||
height: calc(100vh - var(--header-height));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -750,7 +750,7 @@ onUnmounted(() => {
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow, rgba(0, 0, 0, 0.15));
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
padding: 0.5rem 0.75rem;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiGet, apiPatch, listEvents } from "@/api/client";
|
||||
import { fmtCompact } from "@/utils/dateFormat";
|
||||
import { apiGet } from "@/api/client";
|
||||
import GraphView from "@/views/GraphView.vue";
|
||||
import {
|
||||
FileText,
|
||||
CheckSquare,
|
||||
User,
|
||||
MapPin,
|
||||
List,
|
||||
Workflow,
|
||||
Search,
|
||||
Share2,
|
||||
@@ -24,28 +20,17 @@ const router = useRouter();
|
||||
|
||||
interface KnowledgeItem {
|
||||
id: number;
|
||||
note_type: "note" | "person" | "place" | "list" | "task" | "process";
|
||||
note_type: "note" | "task" | "process";
|
||||
title: string;
|
||||
snippet: string;
|
||||
tags: string[];
|
||||
project_id: number | null;
|
||||
metadata: Record<string, string>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// type-specific
|
||||
relationship?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
birthday?: string;
|
||||
organization?: string;
|
||||
address?: string;
|
||||
hours?: string;
|
||||
website?: string;
|
||||
category?: string;
|
||||
item_count?: number;
|
||||
checked_count?: number;
|
||||
list_items?: { text: string; checked: boolean }[];
|
||||
body?: string;
|
||||
// Set only when another user owns this record — their suggestion, not one of
|
||||
// yours. Absent means it's yours.
|
||||
shared?: boolean;
|
||||
owner?: string | null;
|
||||
// Task-specific
|
||||
status?: string;
|
||||
priority?: string;
|
||||
@@ -53,16 +38,9 @@ interface KnowledgeItem {
|
||||
task_kind?: "work" | "plan";
|
||||
}
|
||||
|
||||
interface UpcomingEvent {
|
||||
id: number;
|
||||
title: string;
|
||||
start_dt: string;
|
||||
all_day: boolean;
|
||||
}
|
||||
|
||||
// ─── Filter state ─────────────────────────────────────────────────────────────
|
||||
|
||||
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task" | "plan" | "process">("");
|
||||
const activeType = ref<"" | "note" | "task" | "plan" | "process">("");
|
||||
const activeTag = ref("");
|
||||
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||
const searchQuery = ref("");
|
||||
@@ -70,8 +48,8 @@ let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// ─── Type counts ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface KnowledgeCounts { note: number; person: number; place: number; list: number; task: number; plan: number; process: number; total: number }
|
||||
const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, task: 0, plan: 0, process: 0, total: 0 });
|
||||
interface KnowledgeCounts { note: number; task: number; plan: number; process: number; total: number }
|
||||
const typeCounts = ref<KnowledgeCounts>({ note: 0, task: 0, plan: 0, process: 0, total: 0 });
|
||||
|
||||
async function fetchCounts() {
|
||||
try {
|
||||
@@ -212,26 +190,17 @@ watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
|
||||
|
||||
// ─── Today bar ────────────────────────────────────────────────────────────────
|
||||
|
||||
const upcomingEvents = ref<UpcomingEvent[]>([]);
|
||||
const overdueCount = ref(0);
|
||||
|
||||
async function fetchTodayBar() {
|
||||
try {
|
||||
const now = new Date();
|
||||
const end = new Date(now.getTime() + 7 * 86_400_000);
|
||||
const [events, taskData] = await Promise.all([
|
||||
listEvents(now.toISOString(), end.toISOString()).catch(() => [] as UpcomingEvent[]),
|
||||
apiGet<{ total: number }>(
|
||||
`/api/tasks?status=todo&status=in_progress&overdue=true&limit=1`
|
||||
).catch(() => ({ total: 0 })),
|
||||
]);
|
||||
upcomingEvents.value = events.slice(0, 3);
|
||||
const taskData = await apiGet<{ total: number }>(
|
||||
`/api/tasks?status=todo&status=in_progress&overdue=true&limit=1`
|
||||
).catch(() => ({ total: 0 }));
|
||||
overdueCount.value = taskData.total ?? 0;
|
||||
} catch { /* silent */ }
|
||||
}
|
||||
|
||||
const formatEventDate = fmtCompact
|
||||
|
||||
// ─── Graph panel ──────────────────────────────────────────────────────────────
|
||||
|
||||
const _GRAPH_OPEN_KEY = 'fa_knowledge_graph_open'
|
||||
@@ -250,40 +219,6 @@ function toggleGraphExpand() {
|
||||
localStorage.setItem(_GRAPH_EXP_KEY, String(graphExpanded.value))
|
||||
}
|
||||
|
||||
// ─── List item toggle ─────────────────────────────────────────────────────────
|
||||
|
||||
async function toggleListItem(item: KnowledgeItem, index: number) {
|
||||
if (!item.list_items || item.body === undefined) return;
|
||||
|
||||
// Optimistic update
|
||||
const newChecked = !item.list_items[index].checked;
|
||||
item.list_items[index].checked = newChecked;
|
||||
item.checked_count = item.list_items.filter(i => i.checked).length;
|
||||
|
||||
// Rebuild the body by replacing the targeted checkbox line
|
||||
let listIdx = 0;
|
||||
const newBody = (item.body).split('\n').map(line => {
|
||||
const stripped = line.trimStart();
|
||||
if (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] ')) {
|
||||
if (listIdx === index) {
|
||||
const indent = line.length - stripped.length;
|
||||
const newLine = (' '.repeat(indent)) + (newChecked ? '- [x] ' : '- [ ] ') + item.list_items![index].text;
|
||||
listIdx++;
|
||||
return newLine;
|
||||
}
|
||||
listIdx++;
|
||||
}
|
||||
return line;
|
||||
}).join('\n');
|
||||
|
||||
item.body = newBody;
|
||||
try {
|
||||
await apiPatch(`/api/notes/${item.id}`, { body: newBody });
|
||||
} catch {
|
||||
reset(); // revert on error
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Navigation helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function isOverdue(item: KnowledgeItem): boolean {
|
||||
@@ -352,22 +287,9 @@ onUnmounted(() => {
|
||||
<div class="knowledge-root" :class="{ 'graph-open': graphOpen, 'graph-expanded': graphExpanded && graphOpen }">
|
||||
|
||||
<!-- Today bar -->
|
||||
<div class="today-bar">
|
||||
<div class="today-events">
|
||||
<span v-if="upcomingEvents.length === 0" class="today-empty">No upcoming events</span>
|
||||
<router-link
|
||||
v-for="ev in upcomingEvents"
|
||||
:key="ev.id"
|
||||
:to="`/calendar`"
|
||||
class="today-event-chip"
|
||||
>
|
||||
<span class="chip-dot"></span>
|
||||
{{ ev.title }}
|
||||
<span class="chip-date">{{ formatEventDate(ev.start_dt, ev.all_day) }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-if="overdueCount > 0" class="today-bar">
|
||||
<div class="today-actions">
|
||||
<router-link v-if="overdueCount > 0" to="/tasks" class="overdue-badge">
|
||||
<router-link to="/tasks" class="overdue-badge">
|
||||
{{ overdueCount }} overdue
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -392,18 +314,6 @@ onUnmounted(() => {
|
||||
<CheckSquare :size="16" />
|
||||
Task
|
||||
</button>
|
||||
<button @click="createNew('person')">
|
||||
<User :size="16" />
|
||||
Person
|
||||
</button>
|
||||
<button @click="createNew('place')">
|
||||
<MapPin :size="16" />
|
||||
Place
|
||||
</button>
|
||||
<button @click="createNew('list')">
|
||||
<List :size="16" />
|
||||
List
|
||||
</button>
|
||||
<button @click="createNew('process')">
|
||||
<Workflow :size="16" />
|
||||
Process
|
||||
@@ -422,11 +332,11 @@ onUnmounted(() => {
|
||||
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['person','People','person'],['place','Places','place'],['list','Lists','list'],['process','Processes','process']] as [string,string,string][])"
|
||||
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['process','Processes','process']] as [string,string,string][])"
|
||||
:key="val"
|
||||
class="filter-btn"
|
||||
:class="{ active: activeType === val }"
|
||||
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task' | 'plan' | 'process')"
|
||||
@click="activeType = (val as '' | 'note' | 'task' | 'plan' | 'process')"
|
||||
>
|
||||
<span class="filter-btn-label">{{ label }}</span>
|
||||
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
||||
@@ -471,7 +381,7 @@ onUnmounted(() => {
|
||||
<option value="alpha">Alphabetical</option>
|
||||
<option value="type">By type</option>
|
||||
</select>
|
||||
<button class="btn-graph" :class="{ active: graphOpen }" @click="toggleGraph" title="Toggle graph view">
|
||||
<button class="btn-ghost btn-compact" :class="{ active: graphOpen }" @click="toggleGraph" title="Toggle graph view">
|
||||
<Share2 :size="16" />
|
||||
Graph
|
||||
</button>
|
||||
@@ -496,54 +406,15 @@ onUnmounted(() => {
|
||||
<!-- Type badge -->
|
||||
<span class="type-badge" :class="item.task_kind === 'plan' ? 'badge--plan' : `badge--${item.note_type}`">
|
||||
<span v-if="item.note_type === 'note'">Note</span>
|
||||
<span v-else-if="item.note_type === 'person'">Person</span>
|
||||
<span v-else-if="item.note_type === 'place'">Place</span>
|
||||
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
|
||||
<span v-else-if="item.note_type === 'process'">Process</span>
|
||||
<span v-else>List</span>
|
||||
</span>
|
||||
|
||||
<div class="k-card-body">
|
||||
<div class="k-card-title">{{ item.title }}</div>
|
||||
|
||||
<!-- Person specifics -->
|
||||
<div v-if="item.note_type === 'person'" class="k-card-meta">
|
||||
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
|
||||
<span v-if="item.organization" class="meta-muted">{{ item.organization }}</span>
|
||||
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Place specifics -->
|
||||
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
|
||||
<span v-if="item.category" class="meta-chip">{{ item.category }}</span>
|
||||
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
|
||||
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
|
||||
</div>
|
||||
|
||||
<!-- List specifics -->
|
||||
<div v-else-if="item.note_type === 'list'" class="k-card-list" @click.stop>
|
||||
<label
|
||||
v-for="(li, idx) in (item.list_items ?? []).slice(0, 6)"
|
||||
:key="idx"
|
||||
class="list-item-row"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="li.checked"
|
||||
@change="toggleListItem(item, idx)"
|
||||
/>
|
||||
<span :class="{ 'list-item-done': li.checked }">{{ li.text }}</span>
|
||||
</label>
|
||||
<div v-if="(item.list_items?.length ?? 0) > 6" class="list-item-more">
|
||||
+{{ (item.list_items?.length ?? 0) - 6 }} more
|
||||
</div>
|
||||
<span class="list-progress" style="margin-top: 6px;">
|
||||
<span class="list-progress-bar" :style="{ width: item.item_count ? `${Math.round(((item.checked_count ?? 0) / item.item_count) * 100)}%` : '0%' }"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Task specifics -->
|
||||
<div v-else-if="item.note_type === 'task'" class="k-card-task">
|
||||
<div v-if="item.note_type === 'task'" class="k-card-task">
|
||||
<div class="task-badges">
|
||||
<span class="status-badge" :class="`status--${item.status}`">
|
||||
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
|
||||
@@ -570,6 +441,11 @@ onUnmounted(() => {
|
||||
<div class="k-card-tags">
|
||||
<span v-for="tag in item.tags.slice(0, 3)" :key="tag" class="tag-pill">{{ tag }}</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="item.shared"
|
||||
class="shared-tag"
|
||||
:title="`Shared by ${item.owner ?? 'another user'} — their record, not yours`"
|
||||
>by {{ item.owner ?? "another user" }}</span>
|
||||
<span class="k-card-date">{{ formatDate(item.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -587,14 +463,14 @@ onUnmounted(() => {
|
||||
<span>Graph</span>
|
||||
<div style="display:flex;gap:4px;align-items:center">
|
||||
<button
|
||||
class="btn-icon-sm"
|
||||
class="btn-text"
|
||||
@click="toggleGraphExpand"
|
||||
:title="graphExpanded ? 'Narrow panel' : 'Expand panel'"
|
||||
>
|
||||
<ChevronLeft v-if="graphExpanded" :size="16" />
|
||||
<ChevronRight v-else :size="16" />
|
||||
</button>
|
||||
<button class="btn-icon-sm" @click="toggleGraph" title="Close graph">
|
||||
<button class="btn-text" @click="toggleGraph" title="Close graph">
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -614,7 +490,7 @@ onUnmounted(() => {
|
||||
.knowledge-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--header-height, 56px));
|
||||
height: calc(100vh - var(--header-height));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -626,40 +502,11 @@ onUnmounted(() => {
|
||||
gap: 12px;
|
||||
padding: 8px 20px;
|
||||
background: var(--color-bg-secondary);
|
||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
font-size: 0.82rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.today-events {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.today-empty {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.today-event-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
background: rgba(91, 74, 138, 0.1);
|
||||
border: 1px solid rgba(91, 74, 138, 0.2);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.today-event-chip:hover { background: rgba(91, 74, 138, 0.18); }
|
||||
.chip-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #5B4A8A;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip-date { color: var(--color-muted); font-size: 0.78rem; }
|
||||
.today-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.overdue-badge {
|
||||
padding: 2px 9px;
|
||||
@@ -692,7 +539,7 @@ onUnmounted(() => {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
padding: 16px 12px;
|
||||
border-right: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
||||
border-right: 1px solid var(--color-border);
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
@@ -701,7 +548,7 @@ onUnmounted(() => {
|
||||
content: '· · ·';
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: rgba(91, 74, 138, 0.3);
|
||||
color: color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.4em;
|
||||
padding: 4px 0 12px;
|
||||
@@ -727,7 +574,7 @@ onUnmounted(() => {
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
color: var(--fs-text-on-action);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
@@ -815,7 +662,7 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-btn.active .filter-count {
|
||||
background: rgba(91, 74, 138, 0.2);
|
||||
background: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.filter-tag { font-size: 0.78rem; }
|
||||
@@ -836,7 +683,7 @@ onUnmounted(() => {
|
||||
gap: 10px;
|
||||
padding: 12px 20px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.search-wrap {
|
||||
flex: 1;
|
||||
@@ -854,8 +701,8 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
padding: 7px 12px 7px 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
|
||||
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
font-size: 0.88rem;
|
||||
outline: none;
|
||||
@@ -865,33 +712,13 @@ onUnmounted(() => {
|
||||
.sort-select {
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
|
||||
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.btn-graph {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
|
||||
background: transparent;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-graph:hover { color: var(--color-text); border-color: rgba(255,255,255,0.2); }
|
||||
.btn-graph.active {
|
||||
background: var(--color-primary-wash);
|
||||
border-color: rgba(91, 74, 138, 0.35);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Card grid ───────────────────────────────────────────── */
|
||||
.card-grid {
|
||||
@@ -908,9 +735,9 @@ onUnmounted(() => {
|
||||
|
||||
.k-card {
|
||||
position: relative;
|
||||
background: var(--color-surface, rgba(255,255,255,0.03));
|
||||
border: 1px solid var(--color-border, rgba(255,255,255,0.07));
|
||||
border-radius: var(--radius-lg, 14px);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, transform 0.12s, box-shadow 0.15s;
|
||||
@@ -922,21 +749,17 @@ onUnmounted(() => {
|
||||
}
|
||||
.k-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 28px rgba(91, 74, 138, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(91, 74, 138, 0.35);
|
||||
box-shadow: 0 8px 28px color-mix(in srgb, var(--color-primary) 25%, transparent), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||
}
|
||||
|
||||
/* Type-specific card DNA */
|
||||
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
|
||||
.k-card--note { border-color: color-mix(in srgb, var(--color-primary) 20%, transparent); }
|
||||
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
|
||||
.k-card--person { border-color: rgba(16, 185, 129, 0.18); }
|
||||
.k-card--place { border-color: rgba(245, 158, 11, 0.18); }
|
||||
.k-card--list { border-color: rgba(56, 189, 248, 0.18); }
|
||||
|
||||
/* Top gradient bars */
|
||||
.k-card--note::before,
|
||||
.k-card--task::before,
|
||||
.k-card--list::before {
|
||||
.k-card--task::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -946,31 +769,12 @@ onUnmounted(() => {
|
||||
}
|
||||
.k-card--note::before {
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, #5B4A8A, #7A6DA8);
|
||||
background: linear-gradient(90deg, var(--color-primary), #7A6DA8);
|
||||
}
|
||||
.k-card--task::before {
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, #d4a017, #fbbf24);
|
||||
}
|
||||
.k-card--list::before {
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, #38bdf8, #7dd3fc);
|
||||
}
|
||||
|
||||
/* Corner accents for entity types */
|
||||
.k-card--person::after,
|
||||
.k-card--place::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 0 14px 0 60px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.k-card--person::after { background: rgba(16, 185, 129, 0.06); }
|
||||
.k-card--place::after { background: rgba(245, 158, 11, 0.06); }
|
||||
|
||||
/* Type badge */
|
||||
.type-badge {
|
||||
@@ -984,10 +788,7 @@ onUnmounted(() => {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.badge--note { background: rgba(91, 74, 138,0.15); color: #7A6DA8; }
|
||||
.badge--person { background: rgba(16,185,129,0.15); color: #34d399; }
|
||||
.badge--place { background: rgba(245,158,11,0.15); color: #fbbf24; }
|
||||
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
|
||||
.badge--note { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: #7A6DA8; }
|
||||
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
|
||||
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
|
||||
|
||||
@@ -1012,70 +813,6 @@ onUnmounted(() => {
|
||||
line-height: 1.45;
|
||||
margin: 0;
|
||||
}
|
||||
.k-card-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.meta-chip {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
font-size: 0.75rem;
|
||||
width: fit-content;
|
||||
}
|
||||
.meta-muted { color: var(--color-muted); }
|
||||
|
||||
/* List card items */
|
||||
.k-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.list-item-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.list-item-row input[type="checkbox"] {
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.list-item-done {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.list-item-more {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* List progress bar */
|
||||
.list-progress {
|
||||
display: block;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.list-progress-bar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #38bdf8;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.k-card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1091,6 +828,16 @@ onUnmounted(() => {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
.k-card-date { font-size: 0.72rem; color: var(--color-text-secondary); white-space: nowrap; opacity: 0.7; }
|
||||
/* Only rendered for a record another user owns, so an unmarked card is
|
||||
unambiguously the viewer's own. */
|
||||
.shared-tag {
|
||||
font-size: 0.68rem;
|
||||
padding: 0.08rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
background: color-mix(in srgb, var(--color-text-secondary) 15%, transparent);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ── Task card ──────────────────────────────────────────── */
|
||||
.k-card-task {
|
||||
@@ -1170,7 +917,7 @@ onUnmounted(() => {
|
||||
.graph-panel {
|
||||
width: 500px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
||||
border-left: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-bg-secondary);
|
||||
@@ -1186,25 +933,9 @@ onUnmounted(() => {
|
||||
padding: 10px 14px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-icon-sm {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-muted);
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.btn-icon-sm:hover { color: var(--color-text); }
|
||||
.graph-embed {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
/* Override GraphView's 100vh height so it fills the panel instead */
|
||||
.graph-embed :deep(.graph-page) {
|
||||
height: 100%;
|
||||
|
||||
@@ -84,7 +84,7 @@ function loginWithOAuth() {
|
||||
<p class="forgot-link">
|
||||
<router-link to="/forgot-password">Forgot your password?</router-link>
|
||||
</p>
|
||||
<button type="submit" class="btn-submit" :disabled="submitting">
|
||||
<button type="submit" class="btn-primary btn-block" :disabled="submitting">
|
||||
{{ submitting ? "Signing in..." : "Sign In" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -98,7 +98,7 @@ function loginWithOAuth() {
|
||||
|
||||
<button
|
||||
v-if="authStore.oauthEnabled"
|
||||
class="btn-oauth"
|
||||
class="btn-ghost btn-block"
|
||||
@click="loginWithOAuth"
|
||||
>
|
||||
Login with Authentik
|
||||
@@ -176,24 +176,6 @@ function loginWithOAuth() {
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -208,20 +190,6 @@ function loginWithOAuth() {
|
||||
flex: 1;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.btn-oauth {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-oauth:hover {
|
||||
background: var(--color-bg-hover, var(--color-border));
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -177,7 +177,7 @@ function clearFilters() {
|
||||
<input v-model="dateTo" type="date" class="filter-date" title="To date" />
|
||||
<button
|
||||
v-if="category || search || dateFrom || dateTo"
|
||||
class="btn-clear"
|
||||
class="btn-ghost btn-compact"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Clear
|
||||
@@ -337,19 +337,6 @@ function clearFilters() {
|
||||
.filter-date {
|
||||
width: 140px;
|
||||
}
|
||||
.btn-clear {
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.btn-clear:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.loading-msg,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
||||
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import type { NoteType } from "@/types/note";
|
||||
import { useNotesStore } from "@/stores/notes";
|
||||
@@ -34,85 +34,10 @@ const tags = ref<string[]>([]);
|
||||
const projectId = ref<number | null>(null);
|
||||
const milestoneId = ref<number | null>(null);
|
||||
const noteType = ref<NoteType>("note");
|
||||
const entityMeta = reactive<Record<string, string>>({});
|
||||
const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
const showPreview = ref(false);
|
||||
const sidebarOpen = ref(true);
|
||||
const notesExpanded = ref(false);
|
||||
|
||||
// ── List builder ─────────────────────────────────────────────────────────────
|
||||
interface ListItem {
|
||||
text: string;
|
||||
checked: boolean;
|
||||
}
|
||||
const listItems = ref<ListItem[]>([]);
|
||||
const listItemRefs = ref<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } {
|
||||
const lines = bodyText.split('\n');
|
||||
const items: ListItem[] = [];
|
||||
const extraLines: string[] = [];
|
||||
let pastList = false;
|
||||
for (const line of lines) {
|
||||
const stripped = line.trimStart();
|
||||
if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) {
|
||||
items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') });
|
||||
} else if (!pastList && stripped === '' && items.length > 0) {
|
||||
pastList = true;
|
||||
} else {
|
||||
pastList = true;
|
||||
extraLines.push(line);
|
||||
}
|
||||
}
|
||||
return { items, extra: extraLines.join('\n').trim() };
|
||||
}
|
||||
|
||||
function serializeListToBody(): string {
|
||||
const listPart = listItems.value
|
||||
.map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
|
||||
.join('\n');
|
||||
const extraPart = body.value.trim();
|
||||
return extraPart ? `${listPart}\n\n${extraPart}` : listPart;
|
||||
}
|
||||
|
||||
function addListItem(afterIndex?: number) {
|
||||
const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length;
|
||||
listItems.value.splice(idx, 0, { text: '', checked: false });
|
||||
markDirty();
|
||||
nextTick(() => {
|
||||
listItemRefs.value[idx]?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function removeListItem(index: number) {
|
||||
if (listItems.value.length <= 1) return;
|
||||
listItems.value.splice(index, 1);
|
||||
markDirty();
|
||||
nextTick(() => {
|
||||
const focusIdx = Math.max(0, index - 1);
|
||||
listItemRefs.value[focusIdx]?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function onListItemKeydown(e: KeyboardEvent, index: number) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addListItem(index);
|
||||
} else if (e.key === 'Backspace' && listItems.value[index].text === '') {
|
||||
e.preventDefault();
|
||||
removeListItem(index);
|
||||
}
|
||||
}
|
||||
|
||||
function onListItemInput(_index: number) {
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function toggleListItemCheck(index: number) {
|
||||
listItems.value[index].checked = !listItems.value[index].checked;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
||||
const titleRef = ref<HTMLInputElement | null>(null);
|
||||
@@ -127,9 +52,6 @@ const isEditing = computed(() => noteId.value !== null);
|
||||
|
||||
const titlePlaceholder = computed(() => {
|
||||
switch (noteType.value) {
|
||||
case 'person': return 'Name';
|
||||
case 'place': return 'Place name';
|
||||
case 'list': return 'List title';
|
||||
case 'process': return 'Process name';
|
||||
default: return 'Title';
|
||||
}
|
||||
@@ -276,7 +198,6 @@ let savedTags: string[] = [];
|
||||
let savedProjectId: number | null = null;
|
||||
let savedMilestoneId: number | null = null;
|
||||
let savedNoteType: NoteType = "note";
|
||||
let savedEntityMeta: Record<string, string> = {};
|
||||
|
||||
function markDirty() {
|
||||
dirty.value =
|
||||
@@ -285,8 +206,7 @@ function markDirty() {
|
||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||
projectId.value !== savedProjectId ||
|
||||
milestoneId.value !== savedMilestoneId ||
|
||||
noteType.value !== savedNoteType ||
|
||||
JSON.stringify(entityMeta) !== JSON.stringify(savedEntityMeta);
|
||||
noteType.value !== savedNoteType;
|
||||
}
|
||||
|
||||
function onBodyUpdate(newVal: string) {
|
||||
@@ -304,31 +224,19 @@ onMounted(async () => {
|
||||
projectId.value = store.currentNote.project_id ?? null;
|
||||
milestoneId.value = store.currentNote.milestone_id ?? null;
|
||||
noteType.value = (store.currentNote.note_type as NoteType) || "note";
|
||||
Object.assign(entityMeta, store.currentNote.metadata || {});
|
||||
notesExpanded.value = !!(store.currentNote.body || '').trim();
|
||||
if (noteType.value === 'list') {
|
||||
const parsed = parseListFromBody(body.value);
|
||||
listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }];
|
||||
body.value = parsed.extra;
|
||||
notesExpanded.value = !!parsed.extra;
|
||||
}
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
savedTags = [...tags.value];
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedNoteType = noteType.value;
|
||||
savedEntityMeta = { ...entityMeta };
|
||||
}
|
||||
} else {
|
||||
// New note: read type from query param
|
||||
const qt = route.query.type as string | undefined;
|
||||
if (qt && ["note", "person", "place", "list"].includes(qt)) {
|
||||
if (qt && ["note", "process"].includes(qt)) {
|
||||
noteType.value = qt as NoteType;
|
||||
}
|
||||
if (noteType.value === 'list') {
|
||||
listItems.value = [{ text: '', checked: false }];
|
||||
}
|
||||
}
|
||||
|
||||
// Restore pending draft if any
|
||||
@@ -349,7 +257,7 @@ onMounted(async () => {
|
||||
async function save() {
|
||||
if (saving.value) return;
|
||||
saving.value = true;
|
||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
||||
const finalBody = body.value;
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await store.updateNote(noteId.value!, {
|
||||
@@ -359,7 +267,6 @@ async function save() {
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
note_type: noteType.value,
|
||||
metadata: { ...entityMeta },
|
||||
});
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
@@ -367,7 +274,6 @@ async function save() {
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedNoteType = noteType.value;
|
||||
savedEntityMeta = { ...entityMeta };
|
||||
dirty.value = false;
|
||||
toast.show("Note saved");
|
||||
} else {
|
||||
@@ -378,7 +284,6 @@ async function save() {
|
||||
project_id: projectId.value,
|
||||
milestone_id: milestoneId.value,
|
||||
note_type: noteType.value,
|
||||
metadata: { ...entityMeta },
|
||||
});
|
||||
dirty.value = false;
|
||||
toast.show("Note created");
|
||||
@@ -414,12 +319,12 @@ async function confirmDelete() {
|
||||
async function doAutoSave() {
|
||||
if (!isEditing.value || saving.value) return;
|
||||
saving.value = true;
|
||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
||||
const finalBody = body.value;
|
||||
try {
|
||||
await store.updateNote(noteId.value!, {
|
||||
title: title.value, body: finalBody, tags: tags.value,
|
||||
project_id: projectId.value, milestone_id: milestoneId.value,
|
||||
note_type: noteType.value, metadata: { ...entityMeta },
|
||||
note_type: noteType.value,
|
||||
});
|
||||
savedTitle = title.value;
|
||||
savedBody = body.value;
|
||||
@@ -427,7 +332,6 @@ async function doAutoSave() {
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedNoteType = noteType.value;
|
||||
savedEntityMeta = { ...entityMeta };
|
||||
dirty.value = false;
|
||||
toast.show("Auto-saved");
|
||||
} catch {
|
||||
@@ -473,138 +377,8 @@ onUnmounted(() => assist.clearSelection());
|
||||
|
||||
<!-- ── Main column ────────────────────────────────────────── -->
|
||||
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
|
||||
<!-- ── Person form ──────────────────────────────────────── -->
|
||||
<template v-if="noteType === 'person'">
|
||||
<div class="entity-form">
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Relationship</label>
|
||||
<input class="ef-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague, Family" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Birthday</label>
|
||||
<input class="ef-input" type="date" v-model="entityMeta.birthday" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Email</label>
|
||||
<input class="ef-input" type="email" v-model="entityMeta.email" placeholder="email@example.com" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Phone</label>
|
||||
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Organization</label>
|
||||
<input class="ef-input" v-model="entityMeta.organization" placeholder="Company or organization" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Address</label>
|
||||
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="notes-section">
|
||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
||||
</button>
|
||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Additional notes, wikilinks, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Place form ───────────────────────────────────────── -->
|
||||
<template v-else-if="noteType === 'place'">
|
||||
<div class="entity-form">
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Address</label>
|
||||
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Phone</label>
|
||||
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Hours</label>
|
||||
<input class="ef-input" v-model="entityMeta.hours" placeholder="e.g. Mon–Fri 9am–5pm" @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Website</label>
|
||||
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
|
||||
</div>
|
||||
<div class="ef-field">
|
||||
<label class="ef-label">Category</label>
|
||||
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="notes-section">
|
||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
||||
</button>
|
||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Additional notes, wikilinks, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── List builder ─────────────────────────────────────── -->
|
||||
<template v-else-if="noteType === 'list'">
|
||||
<div class="list-builder">
|
||||
<div
|
||||
v-for="(item, idx) in listItems"
|
||||
:key="idx"
|
||||
class="lb-item"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="item.checked"
|
||||
@change="toggleListItemCheck(idx)"
|
||||
class="lb-check"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<input
|
||||
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
|
||||
v-model="item.text"
|
||||
class="lb-text"
|
||||
placeholder="List item..."
|
||||
@keydown="onListItemKeydown($event, idx)"
|
||||
@input="onListItemInput(idx)"
|
||||
/>
|
||||
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">×</button>
|
||||
</div>
|
||||
<button class="lb-add" @click="addListItem()">+ Add item</button>
|
||||
</div>
|
||||
<div class="notes-section">
|
||||
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
|
||||
{{ notesExpanded ? '▾' : '▸' }} Notes
|
||||
</button>
|
||||
<div v-if="notesExpanded" class="notes-editor-wrap">
|
||||
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
|
||||
<TiptapEditor
|
||||
ref="editorRef"
|
||||
:modelValue="body"
|
||||
placeholder="Additional notes, context..."
|
||||
@update:modelValue="onBodyUpdate"
|
||||
@escape="titleRef?.focus()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Process (prompt) editor ──────────────────────────── -->
|
||||
<template v-else-if="noteType === 'process'">
|
||||
<template v-if="noteType === 'process'">
|
||||
<label class="ef-label">Prompt</label>
|
||||
<textarea
|
||||
class="prompt-editor"
|
||||
@@ -718,9 +492,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
<label class="sb-label">Type</label>
|
||||
<select v-model="noteType" class="sb-select" @change="markDirty">
|
||||
<option value="note">Note</option>
|
||||
<option value="person">Person</option>
|
||||
<option value="place">Place</option>
|
||||
<option value="list">List</option>
|
||||
<option value="process">Process</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -937,8 +709,8 @@ onUnmounted(() => assist.clearSelection());
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-input-border, rgba(255,255,255,0.12));
|
||||
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
|
||||
border: 1px solid var(--color-input-border);
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
@@ -976,7 +748,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-link-all:hover { background: var(--color-primary); color: #fff; }
|
||||
.btn-link-all:hover { background: var(--color-action-primary); color: var(--fs-text-on-action); }
|
||||
|
||||
.link-suggest-list {
|
||||
display: flex;
|
||||
@@ -1034,18 +806,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* ── Entity form (Person / Place) ───────────────────────── */
|
||||
.entity-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
.ef-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
/* ── Process editor ─────────────────────────────────────── */
|
||||
.ef-label {
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 0.92rem;
|
||||
@@ -1061,7 +822,7 @@ onUnmounted(() => assist.clearSelection());
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
/* Prompts are plain markdown — a code-style editor, not rich text. */
|
||||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.55;
|
||||
tab-size: 2;
|
||||
@@ -1071,119 +832,6 @@ onUnmounted(() => assist.clearSelection());
|
||||
.prompt-editor:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.ef-input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.ef-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.ef-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* ── Notes section (collapsible TipTap) ─────────────────── */
|
||||
.notes-section {
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 12px;
|
||||
}
|
||||
.notes-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-family: 'Fraunces', Georgia, serif;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.notes-toggle:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
.notes-editor-wrap {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* ── List builder ───────────────────────────────────────── */
|
||||
.list-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.lb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.lb-check {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.lb-text {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.lb-text:focus {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.lb-text::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.lb-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, color 0.12s;
|
||||
}
|
||||
.lb-item:hover .lb-delete,
|
||||
.lb-text:focus ~ .lb-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
.lb-delete:hover {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.lb-add {
|
||||
background: none;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
margin-top: 4px;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.lb-add:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Narrow screen: sidebar collapses */
|
||||
@media (max-width: 720px) {
|
||||
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
||||
|
||||
@@ -194,22 +194,22 @@ async function convertToTask() {
|
||||
</div>
|
||||
<template v-else-if="store.currentNote">
|
||||
<div class="toolbar">
|
||||
<router-link to="/notes" class="btn-back">← Notes</router-link>
|
||||
<router-link to="/notes" class="btn-ghost">← Notes</router-link>
|
||||
<router-link
|
||||
:to="`/notes/${store.currentNote.id}/edit`"
|
||||
class="btn-edit"
|
||||
class="btn-primary"
|
||||
>
|
||||
Edit
|
||||
</router-link>
|
||||
<button
|
||||
v-if="!store.currentNote.is_task"
|
||||
class="btn-convert"
|
||||
class="btn-secondary btn-compact"
|
||||
@click="convertToTask"
|
||||
:disabled="converting"
|
||||
>
|
||||
{{ converting ? "Converting..." : "Convert to Task" }}
|
||||
</button>
|
||||
<button class="btn-share" @click="showShare = true">Share</button>
|
||||
<button class="btn-secondary btn-compact" @click="showShare = true">Share</button>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb: parent → project → milestone -->
|
||||
@@ -329,81 +329,10 @@ async function convertToTask() {
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-back:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
/* Edit: Moss action-primary — switching from view to edit is operating
|
||||
the software, not a brand moment. */
|
||||
.btn-edit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-edit:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
/* Convert + Share: Bronze action-secondary — alternate paths */
|
||||
.btn-convert {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-action-secondary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-convert:hover { background: var(--color-action-secondary-hover); }
|
||||
.btn-convert:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-share {
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
.note-title {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
|
||||
@@ -113,6 +113,45 @@ function truncate(text: string | null, max = 120): string {
|
||||
return text.length > max ? text.slice(0, max) + "..." : text;
|
||||
}
|
||||
|
||||
// A card is a glance, not a report. Roundtable had ~35 milestones and its tile
|
||||
// ran several viewport-heights tall, which made the grid unreadable (#2391).
|
||||
const MAX_MILESTONE_BARS = 10;
|
||||
|
||||
interface MilestoneBar extends MilestoneSummary {
|
||||
/** Position in the FULL list, so a bar keeps its colour when another
|
||||
* milestone is added or finishes. Tying the palette to the visible index
|
||||
* would recolour the card every time work closed. */
|
||||
paletteIndex: number;
|
||||
}
|
||||
|
||||
/** Bars to draw per project, plus how many were withheld.
|
||||
*
|
||||
* Ordered OPEN WORK FIRST, newest first. Recency alone would be wrong here: a
|
||||
* long-running project's oldest milestones are usually its finished ones, so
|
||||
* showing 10 completed bars while hiding the 3 in flight is worse than showing
|
||||
* nothing. What the card is for is "what is happening", not "what happened".
|
||||
*
|
||||
* Computed once per load rather than called from the template — a helper in a
|
||||
* v-for is re-run on every render, and this one sorts.
|
||||
*/
|
||||
const milestoneBars = computed(() => {
|
||||
const byProject = new Map<number, { bars: MilestoneBar[]; hidden: number }>();
|
||||
for (const project of projects.value) {
|
||||
const all = project.summary?.milestone_summary ?? [];
|
||||
const indexed: MilestoneBar[] = all.map((ms, i) => ({ ...ms, paletteIndex: i }));
|
||||
const newestFirst = (a: MilestoneBar, b: MilestoneBar) => b.id - a.id;
|
||||
const ordered = [
|
||||
...indexed.filter((m) => m.pct < 100).sort(newestFirst),
|
||||
...indexed.filter((m) => m.pct >= 100).sort(newestFirst),
|
||||
];
|
||||
byProject.set(project.id, {
|
||||
bars: ordered.slice(0, MAX_MILESTONE_BARS),
|
||||
hidden: Math.max(0, ordered.length - MAX_MILESTONE_BARS),
|
||||
});
|
||||
}
|
||||
return byProject;
|
||||
});
|
||||
|
||||
function overallPct(project: Project): { total: number; pct: number } {
|
||||
const counts = project.summary?.task_counts;
|
||||
if (!counts) return { total: 0, pct: 0 };
|
||||
@@ -185,11 +224,11 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
|
||||
<!-- Milestone progress bars -->
|
||||
<div
|
||||
v-if="project.summary?.milestone_summary?.length"
|
||||
v-if="milestoneBars.get(project.id)?.bars.length"
|
||||
class="milestone-bars"
|
||||
>
|
||||
<div
|
||||
v-for="(ms, i) in project.summary.milestone_summary"
|
||||
v-for="ms in milestoneBars.get(project.id)!.bars"
|
||||
:key="ms.id"
|
||||
class="milestone-bar-row"
|
||||
:title="`${ms.title} — ${ms.pct}% (${ms.completed}/${ms.total} tasks)`"
|
||||
@@ -198,11 +237,23 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
<div class="milestone-bar-track">
|
||||
<div
|
||||
class="milestone-bar-fill"
|
||||
:style="{ width: ms.pct + '%', background: milestoneColor(i) }"
|
||||
:style="{ width: ms.pct + '%', background: milestoneColor(ms.paletteIndex) }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="milestone-bar-pct">{{ ms.pct }}%</span>
|
||||
</div>
|
||||
<!-- Say what is withheld. A list that simply stops reads as a
|
||||
rendering bug; a count reads as a summary. Plain text, not a
|
||||
link: the whole card already navigates to this project, and a
|
||||
link nested inside a clickable region is a trap for keyboard
|
||||
and screen-reader users. -->
|
||||
<span
|
||||
v-if="milestoneBars.get(project.id)!.hidden"
|
||||
class="milestone-more"
|
||||
>
|
||||
+{{ milestoneBars.get(project.id)!.hidden }}
|
||||
{{ milestoneBars.get(project.id)!.hidden === 1 ? 'more milestone' : 'more milestones' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
@@ -284,20 +335,6 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
|
||||
/* Moss action-primary per Hybrid — list-view utility action,
|
||||
not a brand moment. Empty-state .empty-action below keeps accent. */
|
||||
.btn-primary {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
@@ -340,8 +377,8 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.3; }
|
||||
.empty-title { font-size: 1rem; font-weight: 500; color: var(--color-text-secondary); margin: 0 0 0.35rem; }
|
||||
.empty-sub { font-size: 0.85rem; margin: 0 0 1rem; }
|
||||
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--color-primary); border-radius: var(--radius-sm); color: var(--color-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
|
||||
.empty-action:hover { background: var(--color-primary); color: #fff; }
|
||||
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--color-action-primary); border-radius: var(--radius-sm); color: var(--color-action-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
|
||||
.empty-action:hover { background: var(--color-action-primary); color: var(--fs-text-on-action); }
|
||||
|
||||
.skeleton-card {
|
||||
height: 140px;
|
||||
@@ -506,6 +543,14 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Deliberately quiet — it is a footnote about what is not shown, not another
|
||||
row competing with the bars above it. */
|
||||
.milestone-more {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--fs-size-tiny);
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
@@ -518,7 +563,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--color-overlay, rgba(0,0,0,0.45));
|
||||
background: var(--color-overlay);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -592,9 +637,9 @@ function overallPct(project: Project): { total: number; pct: number } {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.modal-btn-primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
background: var(--color-action-primary);
|
||||
border-color: var(--color-action-primary);
|
||||
color: var(--fs-text-on-action);
|
||||
}
|
||||
.modal-btn-primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
|
||||
+181
-166
@@ -5,8 +5,15 @@ import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import { useTasksStore } from "@/stores/tasks";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import ShareDialog from "@/components/ShareDialog.vue";
|
||||
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
||||
import SystemsSection from "@/components/SystemsSection.vue";
|
||||
import {
|
||||
fetchDesignSystems,
|
||||
setProjectDesignSystem,
|
||||
type DesignSystem,
|
||||
} from "@/api/designSystems";
|
||||
import {
|
||||
LayoutGrid,
|
||||
Clock,
|
||||
@@ -21,6 +28,8 @@ import {
|
||||
interface Milestone {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
body: string | null;
|
||||
status: string;
|
||||
order_index: number;
|
||||
pct: number;
|
||||
@@ -37,6 +46,7 @@ interface Project {
|
||||
goal: string | null;
|
||||
status: "active" | "paused" | "completed" | "archived";
|
||||
color: string | null;
|
||||
design_system_id: number | null;
|
||||
permission?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -65,6 +75,12 @@ const toast = useToastStore();
|
||||
const tasksStore = useTasksStore();
|
||||
|
||||
const project = ref<Project | null>(null);
|
||||
|
||||
// Design system the project is styled from. Loaded separately because an
|
||||
// install with none is the ordinary case (rule #115) and the picker simply
|
||||
// doesn't render — a failed fetch must not take the project page with it.
|
||||
const designSystems = ref<DesignSystem[]>([]);
|
||||
const editDesignSystemId = ref<number | null>(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const showStartPlanning = ref(false);
|
||||
@@ -76,10 +92,15 @@ async function confirmStartPlanning() {
|
||||
if (!title || !project.value) return;
|
||||
planningBusy.value = true;
|
||||
try {
|
||||
// start_planning creates a MILESTONE (the plan container). Reload milestones,
|
||||
// make sure the new one is expanded, and open its plan editor.
|
||||
const result = await tasksStore.startPlanning(project.value.id, title);
|
||||
planTitle.value = "";
|
||||
showStartPlanning.value = false;
|
||||
router.push(`/tasks/${result.task.id}`);
|
||||
await loadMilestones();
|
||||
collapsedMilestones.value.delete(result.milestone.id);
|
||||
startEditPlan(result.milestone.id, result.milestone.body);
|
||||
toast.show("Plan started");
|
||||
} finally {
|
||||
planningBusy.value = false;
|
||||
}
|
||||
@@ -87,7 +108,7 @@ async function confirmStartPlanning() {
|
||||
const saving = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const activeTab = ref<"tasks" | "notes" | "rules">("tasks");
|
||||
const activeTab = ref<"tasks" | "notes" | "systems" | "rules">("tasks");
|
||||
|
||||
const tasks = ref<NoteItem[]>([]);
|
||||
const notes = ref<NoteItem[]>([]);
|
||||
@@ -166,6 +187,7 @@ async function loadProject() {
|
||||
editDescription.value = data.description ?? "";
|
||||
editGoal.value = data.goal ?? "";
|
||||
editStatus.value = data.status;
|
||||
editDesignSystemId.value = data.design_system_id ?? null;
|
||||
editDirty.value = false;
|
||||
milestones.value = data.summary?.milestone_summary ?? [];
|
||||
autoCollapseCompleted(milestones.value);
|
||||
@@ -224,6 +246,39 @@ async function commitRenameMilestone(ms: Milestone) {
|
||||
}
|
||||
}
|
||||
|
||||
// Plan body editing — a milestone IS the plan; its `body` holds the design.
|
||||
const editingPlanId = ref<number | null>(null);
|
||||
const editPlanBody = ref("");
|
||||
const savingPlan = ref(false);
|
||||
|
||||
function startEditPlan(id: number, body: string | null) {
|
||||
editingPlanId.value = id;
|
||||
editPlanBody.value = body ?? "";
|
||||
}
|
||||
|
||||
function cancelEditPlan() {
|
||||
editingPlanId.value = null;
|
||||
editPlanBody.value = "";
|
||||
}
|
||||
|
||||
async function commitEditPlan(ms: Milestone) {
|
||||
if (savingPlan.value) return;
|
||||
savingPlan.value = true;
|
||||
try {
|
||||
await apiPatch(`/api/projects/${projectId.value}/milestones/${ms.id}`, {
|
||||
body: editPlanBody.value,
|
||||
});
|
||||
await loadMilestones();
|
||||
editingPlanId.value = null;
|
||||
editPlanBody.value = "";
|
||||
toast.show("Plan saved");
|
||||
} catch {
|
||||
toast.show("Failed to save plan", "error");
|
||||
} finally {
|
||||
savingPlan.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteMilestone() {
|
||||
const ms = deletingMilestone.value;
|
||||
if (!ms) return;
|
||||
@@ -294,8 +349,20 @@ onMounted(async () => {
|
||||
await loadProject();
|
||||
loadTasks();
|
||||
loadNotes();
|
||||
loadDesignSystems();
|
||||
});
|
||||
|
||||
/** Populate the design-system picker. Swallows failure on purpose: with no
|
||||
* design systems the picker doesn't render at all, which is the ordinary state
|
||||
* for most installs — so this must never be able to break the project page. */
|
||||
async function loadDesignSystems() {
|
||||
try {
|
||||
designSystems.value = (await fetchDesignSystems()).design_systems;
|
||||
} catch {
|
||||
designSystems.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
watch(projectId, async () => {
|
||||
await loadProject();
|
||||
loadTasks();
|
||||
@@ -303,28 +370,39 @@ watch(projectId, async () => {
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [editTitle.value, editDescription.value, editGoal.value, editStatus.value],
|
||||
() => [editTitle.value, editDescription.value, editGoal.value, editStatus.value, editDesignSystemId.value],
|
||||
() => {
|
||||
if (!project.value) return;
|
||||
editDirty.value =
|
||||
editTitle.value !== project.value.title ||
|
||||
editDescription.value !== (project.value.description ?? "") ||
|
||||
editGoal.value !== (project.value.goal ?? "") ||
|
||||
editStatus.value !== project.value.status;
|
||||
editStatus.value !== project.value.status ||
|
||||
editDesignSystemId.value !== (project.value.design_system_id ?? null);
|
||||
}
|
||||
);
|
||||
|
||||
async function saveProject() {
|
||||
if (!project.value || saving.value) return;
|
||||
// Bound once rather than re-read: the checks below straddle two awaits, and
|
||||
// `project.value` is a ref whose narrowing doesn't survive them.
|
||||
const current = project.value;
|
||||
if (!current || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const updated = await apiPatch<Project>(`/api/projects/${project.value.id}`, {
|
||||
const updated = await apiPatch<Project>(`/api/projects/${current.id}`, {
|
||||
title: editTitle.value.trim(),
|
||||
description: editDescription.value.trim() || null,
|
||||
goal: editGoal.value.trim() || null,
|
||||
status: editStatus.value,
|
||||
});
|
||||
project.value = { ...project.value, ...updated };
|
||||
// The design-system pointer is its own endpoint (PUT, because clearing it
|
||||
// is a real outcome rather than an omission), so it saves separately —
|
||||
// only when it actually changed, to keep the common save at one request.
|
||||
if (editDesignSystemId.value !== (current.design_system_id ?? null)) {
|
||||
await setProjectDesignSystem(current.id, editDesignSystemId.value);
|
||||
updated.design_system_id = editDesignSystemId.value;
|
||||
}
|
||||
project.value = { ...current, ...updated };
|
||||
editDirty.value = false;
|
||||
toast.show("Project saved");
|
||||
} catch {
|
||||
@@ -357,7 +435,7 @@ async function confirmDelete() {
|
||||
|
||||
<!-- Nav bar -->
|
||||
<div class="page-header">
|
||||
<router-link to="/projects" class="btn-back">← Projects</router-link>
|
||||
<router-link to="/projects" class="btn-ghost">← Projects</router-link>
|
||||
<div class="page-header-actions">
|
||||
<template v-if="showStartPlanning">
|
||||
<input
|
||||
@@ -373,7 +451,7 @@ async function confirmDelete() {
|
||||
>
|
||||
Create plan
|
||||
</button>
|
||||
<button class="btn-share" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
|
||||
<button class="btn-secondary btn-compact" @click="showStartPlanning = false; planTitle = ''">Cancel</button>
|
||||
</template>
|
||||
<button
|
||||
v-else-if="project"
|
||||
@@ -386,7 +464,7 @@ async function confirmDelete() {
|
||||
<LayoutGrid :size="16" />
|
||||
Workspace
|
||||
</router-link>
|
||||
<button v-if="project && !showStartPlanning" class="btn-share" @click="showShare = true">Share</button>
|
||||
<button v-if="project && !showStartPlanning" class="btn-secondary btn-compact" @click="showShare = true">Share</button>
|
||||
<button v-if="project && !showStartPlanning" class="btn-danger-outline" @click="showDeleteConfirm = true">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -463,7 +541,14 @@ async function confirmDelete() {
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-save-panel" @click="saveProject" :disabled="!editDirty || saving">
|
||||
<div v-if="designSystems.length" class="edit-field">
|
||||
<label class="edit-label" for="project-design-system">Design system</label>
|
||||
<select id="project-design-system" v-model="editDesignSystemId" class="edit-select">
|
||||
<option :value="null">None</option>
|
||||
<option v-for="ds in designSystems" :key="ds.id" :value="ds.id">{{ ds.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary" @click="saveProject" :disabled="!editDirty || saving">
|
||||
{{ saving ? "Saving..." : "Save Changes" }}
|
||||
</button>
|
||||
</aside>
|
||||
@@ -479,6 +564,9 @@ async function confirmDelete() {
|
||||
Notes
|
||||
<span v-if="project.summary" class="tab-count">{{ project.summary.note_count }}</span>
|
||||
</button>
|
||||
<button :class="['tab-btn', { active: activeTab === 'systems' }]" @click="activeTab = 'systems'">
|
||||
Systems
|
||||
</button>
|
||||
<button :class="['tab-btn', { active: activeTab === 'rules' }]" @click="activeTab = 'rules'">
|
||||
Rules
|
||||
</button>
|
||||
@@ -493,7 +581,7 @@ async function confirmDelete() {
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="milestone-actions">
|
||||
<button v-if="!showNewMilestone" class="btn-add-milestone" @click="showNewMilestone = true">
|
||||
<button v-if="!showNewMilestone" class="btn-ghost btn-inline btn-add-milestone" @click="showNewMilestone = true">
|
||||
+ Milestone
|
||||
</button>
|
||||
<div v-else class="new-milestone-row">
|
||||
@@ -505,10 +593,10 @@ async function confirmDelete() {
|
||||
@keydown.enter="createMilestone"
|
||||
@keydown.escape="showNewMilestone = false; newMilestoneTitle = ''"
|
||||
/>
|
||||
<button class="btn-ms-confirm" @click="createMilestone" :disabled="!newMilestoneTitle.trim() || creatingMilestone">
|
||||
<button class="btn-primary btn-compact" @click="createMilestone" :disabled="!newMilestoneTitle.trim() || creatingMilestone">
|
||||
{{ creatingMilestone ? "..." : "Add" }}
|
||||
</button>
|
||||
<button class="btn-ms-cancel" @click="showNewMilestone = false; newMilestoneTitle = ''">Cancel</button>
|
||||
<button class="btn-secondary btn-compact" @click="showNewMilestone = false; newMilestoneTitle = ''">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -541,6 +629,9 @@ async function confirmDelete() {
|
||||
</div>
|
||||
<span class="ms-pct">{{ group.milestone.pct }}%</span>
|
||||
<div class="ms-actions" @click.stop>
|
||||
<button class="ms-action-btn" :title="group.milestone.body ? 'Edit plan' : 'Add plan'" @click="startEditPlan(group.milestone.id, group.milestone.body)">
|
||||
<FileText :size="16" />
|
||||
</button>
|
||||
<button class="ms-action-btn" title="Rename" @click="startRenameMilestone(group.milestone)">
|
||||
<Pencil :size="16" />
|
||||
</button>
|
||||
@@ -551,6 +642,31 @@ async function confirmDelete() {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Plan body: the milestone IS the plan; design lives here, steps are the tasks below. -->
|
||||
<div
|
||||
v-if="group.milestone && !collapsedMilestones.has(group.milestone.id) && (editingPlanId === group.milestone.id || group.milestone.body)"
|
||||
class="ms-plan"
|
||||
>
|
||||
<template v-if="editingPlanId === group.milestone.id">
|
||||
<textarea
|
||||
v-model="editPlanBody"
|
||||
class="ms-plan-editor"
|
||||
rows="10"
|
||||
placeholder="The plan: Goal / Approach / Verification. Track each step as a task below."
|
||||
></textarea>
|
||||
<div class="ms-plan-actions">
|
||||
<button class="btn-secondary" @click="cancelEditPlan">Cancel</button>
|
||||
<button class="btn-primary" :disabled="savingPlan" @click="commitEditPlan(group.milestone)">Save plan</button>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="ms-plan-rendered markdown-body"
|
||||
@click="startEditPlan(group.milestone.id, group.milestone.body)"
|
||||
v-html="renderMarkdown(group.milestone.body || '')"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div v-if="!group.milestone || !collapsedMilestones.has(group.milestone.id)" class="kanban">
|
||||
<!-- Todo column -->
|
||||
<div class="kanban-col col-todo">
|
||||
@@ -663,6 +779,9 @@ async function confirmDelete() {
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Systems tab -->
|
||||
<SystemsSection v-if="activeTab === 'systems'" :project-id="projectId" />
|
||||
|
||||
<!-- Rules tab -->
|
||||
<ProjectRulesTab v-if="activeTab === 'rules'" :project-id="projectId" />
|
||||
</div>
|
||||
@@ -728,89 +847,18 @@ async function confirmDelete() {
|
||||
}
|
||||
.page-header-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.plan-title-input {
|
||||
background: var(--color-bg, #111113);
|
||||
background: var(--color-bg);
|
||||
color: inherit;
|
||||
border: 1px solid var(--color-border, #2a2a2e);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
font: inherit;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
/* Open Workspace: brand-moment CTA — keep accent gradient. Workspace is
|
||||
the project's "central feature moment" — entering the focused workspace
|
||||
is a Scribe-flavored action, not a plain operation. */
|
||||
.btn-workspace {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--gradient-cta);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
box-shadow: var(--glow-cta);
|
||||
transition: box-shadow 0.15s, opacity 0.15s;
|
||||
}
|
||||
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: #fff; }
|
||||
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: var(--fs-text-on-action); }
|
||||
|
||||
/* Share: Bronze action-secondary — alternate path */
|
||||
.btn-share {
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* Delete project: Oxblood action-destructive ghost — outline form since
|
||||
the actual confirm modal carries the filled destructive treatment */
|
||||
.btn-danger-outline {
|
||||
padding: 0.4rem 0.8rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-action-destructive);
|
||||
color: var(--color-action-destructive);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.btn-danger-outline:hover { background: var(--color-action-destructive); color: #fff; }
|
||||
|
||||
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
|
||||
|
||||
/* ── Project identity header ─────────────────────────────────── */
|
||||
.project-header { margin-bottom: 1rem; }
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.project-title-input {
|
||||
flex: 1;
|
||||
font-size: 1.75rem;
|
||||
@@ -884,8 +932,8 @@ async function confirmDelete() {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot-todo { background: transparent; border: 2px solid var(--color-text-muted); }
|
||||
.dot-inprogress { background: var(--color-status-in-progress, #3b82f6); }
|
||||
.dot-done { background: var(--color-status-done, #22c55e); }
|
||||
.dot-inprogress { background: var(--color-status-in-progress); }
|
||||
.dot-done { background: var(--color-status-done); }
|
||||
|
||||
.stat-todo { background: color-mix(in srgb, var(--color-text-muted) 8%, transparent); color: var(--color-text-secondary); border-color: var(--color-border); }
|
||||
.stat-inprogress { background: color-mix(in srgb, #3b82f6 10%, transparent); color: #3b82f6; border-color: color-mix(in srgb, #3b82f6 28%, transparent); }
|
||||
@@ -943,30 +991,6 @@ async function confirmDelete() {
|
||||
.edit-textarea { resize: vertical; }
|
||||
|
||||
/* Save panel: Moss action-primary per Hybrid rule */
|
||||
.btn-save-panel {
|
||||
padding: 0.45rem 0.9rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-save-panel:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-save-panel:disabled { opacity: 0.45; cursor: default; }
|
||||
|
||||
/* ── Content area ────────────────────────────────────────────── */
|
||||
.content-area { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.tab-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1029,52 +1053,43 @@ async function confirmDelete() {
|
||||
}
|
||||
.milestone-title-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
/* Milestone confirm: Moss action-primary; Cancel: Bronze action-secondary */
|
||||
.btn-ms-confirm {
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-ms-confirm:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
||||
.btn-ms-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-ms-cancel {
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-ms-cancel:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
/* ── Milestone group ─────────────────────────────────────────── */
|
||||
.milestone-group {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
|
||||
}
|
||||
.milestone-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.85rem;
|
||||
background: var(--color-bg-secondary);
|
||||
font-size: 0.85rem;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.milestone-header.clickable { cursor: pointer; }
|
||||
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
|
||||
|
||||
/* Plan body: the milestone's design/intent, shown above its task columns. */
|
||||
.ms-plan {
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 3%, var(--color-bg-card));
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.ms-plan-rendered { font-size: 0.85rem; color: var(--color-text); cursor: text; }
|
||||
.ms-plan-rendered:hover { background: color-mix(in srgb, var(--color-primary) 4%, transparent); }
|
||||
.ms-plan-editor {
|
||||
width: 100%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ms-plan-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 0.5rem; }
|
||||
.ms-plan-actions .btn-primary,
|
||||
.ms-plan-actions .btn-secondary {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.ms-plan-actions .btn-primary { background: var(--color-action-primary); color: var(--fs-text-on-action); border-color: var(--color-action-primary); }
|
||||
.ms-plan-actions .btn-primary:disabled { opacity: 0.6; cursor: default; }
|
||||
.ms-plan-actions .btn-secondary { background: var(--color-bg-card); color: var(--color-text); }
|
||||
|
||||
.ms-chevron { display: flex; align-items: center; color: var(--color-text-muted); flex-shrink: 0; }
|
||||
.ms-name { font-weight: 500; color: var(--color-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ms-count {
|
||||
@@ -1118,7 +1133,7 @@ async function confirmDelete() {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.ms-action-btn:hover { background: var(--color-bg-card); color: var(--color-text); }
|
||||
.ms-action-delete:hover { color: var(--color-danger, #e74c3c); }
|
||||
.ms-action-delete:hover { color: var(--color-danger); }
|
||||
|
||||
.ms-rename-input {
|
||||
flex: 1;
|
||||
@@ -1153,8 +1168,8 @@ async function confirmDelete() {
|
||||
border-top: 3px solid;
|
||||
}
|
||||
.col-todo { border-top-color: var(--color-border); }
|
||||
.col-inprogress { border-top-color: var(--color-status-in-progress, #3b82f6); }
|
||||
.col-done { border-top-color: var(--color-status-done, #22c55e); }
|
||||
.col-inprogress { border-top-color: var(--color-status-in-progress); }
|
||||
.col-done { border-top-color: var(--color-status-done); }
|
||||
|
||||
.kanban-col-header {
|
||||
display: flex;
|
||||
@@ -1215,7 +1230,7 @@ async function confirmDelete() {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
/* Priority left-border colors */
|
||||
.task-card.pri-high { border-left-color: var(--color-danger, #e74c3c); }
|
||||
.task-card.pri-high { border-left-color: var(--color-danger); }
|
||||
.task-card.pri-medium { border-left-color: #f59e0b; }
|
||||
.task-card.pri-low { border-left-color: var(--color-success); }
|
||||
|
||||
@@ -1240,8 +1255,8 @@ async function confirmDelete() {
|
||||
line-height: 1;
|
||||
}
|
||||
.task-card:hover .task-advance-btn { opacity: 1; }
|
||||
.task-advance-btn:hover { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
|
||||
.task-advance-btn--done:hover { background: var(--color-success, #22c55e); border-color: var(--color-success, #22c55e); color: #fff; }
|
||||
.task-advance-btn:hover { background: var(--color-action-primary); border-color: var(--color-action-primary); color: var(--fs-text-on-action); }
|
||||
.task-advance-btn--done:hover { background: var(--color-success); border-color: var(--color-success); color: var(--fs-text-on-action); }
|
||||
.task-advance-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
.priority-dot {
|
||||
@@ -1250,7 +1265,7 @@ async function confirmDelete() {
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot-pri-high { background: var(--color-danger, #e74c3c); }
|
||||
.dot-pri-high { background: var(--color-danger); }
|
||||
.dot-pri-medium { background: #f59e0b; }
|
||||
.dot-pri-low { background: var(--color-success); }
|
||||
|
||||
@@ -1295,7 +1310,7 @@ async function confirmDelete() {
|
||||
/* ── Modal ───────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: var(--color-overlay, rgba(0,0,0,0.45));
|
||||
background: var(--color-overlay);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
@@ -1322,7 +1337,7 @@ async function confirmDelete() {
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-btn:hover { background: var(--color-bg); }
|
||||
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: #fff; }
|
||||
.modal-btn-danger { background: var(--color-action-destructive); border-color: var(--color-action-destructive); color: var(--fs-text-on-action); }
|
||||
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
|
||||
|
||||
/* ── Skeleton ────────────────────────────────────────────────── */
|
||||
|
||||
@@ -144,7 +144,7 @@ async function handleSubmit() {
|
||||
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
|
||||
</div>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<button type="submit" class="btn-submit" :disabled="!canSubmit">
|
||||
<button type="submit" class="btn-primary btn-block" :disabled="!canSubmit">
|
||||
{{ submitting ? "Creating Account..." : "Create Account" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -247,24 +247,6 @@ async function handleSubmit() {
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -117,7 +117,7 @@ async function handleSubmit() {
|
||||
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
|
||||
</div>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<button type="submit" class="btn-submit" :disabled="!canSubmit">
|
||||
<button type="submit" class="btn-primary btn-block" :disabled="!canSubmit">
|
||||
{{ submitting ? "Creating account..." : "Create Account" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -216,24 +216,6 @@ async function handleSubmit() {
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -88,7 +88,7 @@ async function handleSubmit() {
|
||||
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
|
||||
</div>
|
||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
||||
<button type="submit" class="btn-submit" :disabled="!canSubmit">
|
||||
<button type="submit" class="btn-primary btn-block" :disabled="!canSubmit">
|
||||
{{ submitting ? "Resetting..." : "Reset Password" }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -195,24 +195,6 @@ async function handleSubmit() {
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 0.6rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -107,10 +107,10 @@ watch(() => route.query, syncFromRoute);
|
||||
grid-template-columns: 280px 300px 1fr;
|
||||
height: 100vh;
|
||||
gap: 1px;
|
||||
background: var(--color-border, #2a2a2e);
|
||||
background: var(--color-border);
|
||||
}
|
||||
.pane.empty {
|
||||
background: var(--color-surface, #18181b);
|
||||
background: var(--color-surface);
|
||||
padding: 1rem;
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
|
||||
+584
-443
File diff suppressed because it is too large
Load Diff
@@ -244,7 +244,7 @@ onMounted(async () => {
|
||||
}
|
||||
.perm-viewer { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }
|
||||
.perm-editor { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
|
||||
.perm-admin { background: color-mix(in srgb, var(--color-warning, #f59e0b) 15%, transparent); color: var(--color-warning, #f59e0b); }
|
||||
.perm-admin { background: color-mix(in srgb, var(--color-warning) 15%, transparent); color: var(--color-warning); }
|
||||
|
||||
.empty-msg {
|
||||
color: var(--color-muted);
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
getSnippet,
|
||||
deleteSnippet,
|
||||
unmergeSnippet,
|
||||
type Snippet,
|
||||
} from "@/api/snippets";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToastStore();
|
||||
|
||||
const snippet = ref<Snippet | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const showDeleteConfirm = ref(false);
|
||||
|
||||
const id = computed(() => Number(route.params.id));
|
||||
|
||||
const canWrite = computed(() => {
|
||||
const p = snippet.value?.permission;
|
||||
return p === undefined || p === "owner" || p === "edit" || p === "admin";
|
||||
});
|
||||
|
||||
const locations = computed(() => snippet.value?.snippet.locations ?? []);
|
||||
|
||||
// What this record absorbed. Merge keeps the target's fields and trashes the
|
||||
// sources, so this line is the only visible trace that the variants existed.
|
||||
const mergedFrom = computed(() => snippet.value?.snippet.merged_from ?? []);
|
||||
|
||||
function locParts(loc: { repo: string; path: string; symbol: string }): string[] {
|
||||
return [loc.repo, loc.path, loc.symbol].filter((p) => p && p.trim());
|
||||
}
|
||||
|
||||
// Un-merge (#2165)
|
||||
const unmerging = ref<number | null>(null);
|
||||
|
||||
/** Only entries carrying what the source contributed can be reversed exactly.
|
||||
* Without that, subtraction would be a guess that could strip call sites the
|
||||
* survivor owns in its own right — so the control isn't offered. */
|
||||
function canUnmerge(entry: { locations?: unknown[]; tags?: unknown[] }): boolean {
|
||||
return canWrite.value && (!!entry.locations?.length || !!entry.tags?.length);
|
||||
}
|
||||
|
||||
async function doUnmerge(sourceId: number) {
|
||||
unmerging.value = sourceId;
|
||||
try {
|
||||
await unmergeSnippet(id.value, sourceId);
|
||||
toast.show(`#${sourceId} pulled back out and restored`);
|
||||
await load();
|
||||
} catch (e: unknown) {
|
||||
// 409 carries the reason the record's state makes it impossible — show it
|
||||
// rather than a generic failure, since it's the actionable part.
|
||||
const detail = (e as { body?: { error?: string } }).body?.error;
|
||||
toast.show(detail || `Couldn't un-merge #${sourceId}`, "error");
|
||||
} finally {
|
||||
unmerging.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
snippet.value = await getSnippet(id.value);
|
||||
} catch (e: unknown) {
|
||||
const status = (e as { status?: number }).status;
|
||||
error.value = status === 404
|
||||
? "This snippet couldn't be found."
|
||||
: "Couldn't load this snippet.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
async function copyCode() {
|
||||
const code = snippet.value?.snippet.code ?? "";
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
toast.show("Code copied");
|
||||
} catch {
|
||||
toast.show("Couldn't copy — select and copy manually", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
try {
|
||||
await deleteSnippet(id.value);
|
||||
toast.show("Snippet deleted");
|
||||
router.push("/snippets");
|
||||
} catch {
|
||||
toast.show("Failed to delete snippet", "error");
|
||||
} finally {
|
||||
showDeleteConfirm.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="snippet-detail">
|
||||
<router-link to="/snippets" class="back-link">← Snippets</router-link>
|
||||
|
||||
<div v-if="loading" class="state-msg">Fetching the snippet…</div>
|
||||
<p v-else-if="error" class="error-msg">{{ error }}</p>
|
||||
|
||||
<template v-else-if="snippet">
|
||||
<div class="detail-header">
|
||||
<h1 class="snippet-name">{{ snippet.snippet.name }}</h1>
|
||||
<div class="header-actions" v-if="canWrite">
|
||||
<button class="btn-ghost" @click="router.push(`/snippets/${id}/edit`)">Edit</button>
|
||||
<button class="btn-danger" @click="showDeleteConfirm = true">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="snippet.shared" class="shared-notice">
|
||||
Shared by <strong>{{ snippet.owner ?? "another user" }}</strong> — their
|
||||
suggestion, not one of your own records. Worth weighing on its merits
|
||||
before you build on it.
|
||||
</p>
|
||||
|
||||
<p v-if="snippet.snippet.when_to_use" class="when-to-use">
|
||||
{{ snippet.snippet.when_to_use }}
|
||||
</p>
|
||||
|
||||
<dl class="meta-grid">
|
||||
<template v-if="snippet.snippet.signature">
|
||||
<dt>Signature</dt>
|
||||
<dd><code>{{ snippet.snippet.signature }}</code></dd>
|
||||
</template>
|
||||
<template v-if="locations.length">
|
||||
<dt>{{ locations.length > 1 ? "Locations" : "Location" }}</dt>
|
||||
<dd class="location-list">
|
||||
<div v-for="(loc, i) in locations" :key="i" class="location">
|
||||
<code v-for="(p, j) in locParts(loc)" :key="j">{{ p }}</code>
|
||||
</div>
|
||||
</dd>
|
||||
</template>
|
||||
<template v-if="snippet.snippet.language">
|
||||
<dt>Language</dt>
|
||||
<dd>{{ snippet.snippet.language }}</dd>
|
||||
</template>
|
||||
<template v-if="mergedFrom.length">
|
||||
<dt>Merged from</dt>
|
||||
<dd class="merged-from">
|
||||
<span v-for="m in mergedFrom" :key="m.id" class="merged-entry">
|
||||
<span>#{{ m.id }}</span>
|
||||
<button
|
||||
v-if="canUnmerge(m)"
|
||||
class="unmerge-btn"
|
||||
:disabled="unmerging === m.id"
|
||||
:title="`Pull #${m.id} back out: restore it and remove the ${(m.locations ?? []).length} location(s) it contributed`"
|
||||
@click="doUnmerge(m.id)"
|
||||
>
|
||||
{{ unmerging === m.id ? "…" : "un-merge" }}
|
||||
</button>
|
||||
<!-- Says why rather than hiding the control: a disabled thing with
|
||||
no explanation reads as a bug. -->
|
||||
<span
|
||||
v-else-if="canWrite"
|
||||
class="unmerge-na"
|
||||
:title="`This merge predates per-source provenance, so what #${m.id} contributed isn't recorded. Restore it from the trash and adjust both records by hand — subtracting a guess could strip call sites this record genuinely owns.`"
|
||||
>
|
||||
(not reversible)
|
||||
</span>
|
||||
</span>
|
||||
<span class="merged-hint">
|
||||
folded in here — the originals are in the trash
|
||||
</span>
|
||||
</dd>
|
||||
</template>
|
||||
</dl>
|
||||
|
||||
<div class="code-block">
|
||||
<div class="code-bar">
|
||||
<span class="code-lang">{{ snippet.snippet.language || "code" }}</span>
|
||||
<button class="btn-ghost btn-copy" @click="copyCode">Copy</button>
|
||||
</div>
|
||||
<pre><code>{{ snippet.snippet.code }}</code></pre>
|
||||
</div>
|
||||
|
||||
<div v-if="snippet.tags.length" class="tag-row">
|
||||
<span v-for="t in snippet.tags" :key="t" class="tag-pill">{{ t }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ConfirmDialog
|
||||
v-if="showDeleteConfirm"
|
||||
title="Delete snippet"
|
||||
:message="`Delete “${snippet?.snippet.name}”? This can be restored from the trash.`"
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
@confirm="confirmDelete"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.snippet-detail {
|
||||
max-width: 820px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--page-padding-x);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.back-link:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.state-msg {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
.snippet-name {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1.4rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
.when-to-use {
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* Shown only for a record another user owns. Deliberately above the code: the
|
||||
provenance has to be read before the implementation is trusted. */
|
||||
.shared-notice {
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-left: 3px solid var(--color-text-muted);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-secondary);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.4rem 1rem;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
.meta-grid dt {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
.meta-grid dd {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
}
|
||||
.meta-grid code,
|
||||
.tag-row + * code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
||||
color: var(--color-primary);
|
||||
padding: 0.08rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
word-break: break-all;
|
||||
}
|
||||
.location-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.location {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
.merged-from {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
.merged-hint {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.merged-entry {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.unmerge-btn {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.unmerge-btn:hover:not(:disabled) {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
.unmerge-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.unmerge-na {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
/* Cursor cues that the explanation is in the tooltip. */
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
.code-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.4rem 0.5rem 0.4rem 0.85rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.code-lang {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.btn-copy {
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.code-block pre {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.code-block code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text);
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
.tag-pill {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.detail-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
.meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.15rem 0;
|
||||
}
|
||||
.meta-grid dd {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,582 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
getSnippet,
|
||||
createSnippet,
|
||||
updateSnippet,
|
||||
type SnippetInput,
|
||||
type SnippetLocation,
|
||||
} from "@/api/snippets";
|
||||
import { ApiError } from "@/api/client";
|
||||
import ProjectSelector from "@/components/ProjectSelector.vue";
|
||||
import { useSystemsStore } from "@/stores/systems";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToastStore();
|
||||
const systemsStore = useSystemsStore();
|
||||
|
||||
const editId = computed(() => (route.params.id ? Number(route.params.id) : null));
|
||||
const isEditing = computed(() => editId.value !== null);
|
||||
|
||||
// All-string scalar state (tags/locations handled separately) so v-model and
|
||||
// .trim() never meet an undefined.
|
||||
interface FormState {
|
||||
name: string;
|
||||
code: string;
|
||||
language: string;
|
||||
signature: string;
|
||||
when_to_use: string;
|
||||
}
|
||||
|
||||
const blankLocation = (): SnippetLocation => ({ repo: "", path: "", symbol: "" });
|
||||
|
||||
const form = ref<FormState>({
|
||||
name: "",
|
||||
code: "",
|
||||
language: "",
|
||||
signature: "",
|
||||
when_to_use: "",
|
||||
});
|
||||
// A snippet that unified several one-offs carries several locations; a fresh one
|
||||
// starts with a single blank row.
|
||||
const locations = ref<SnippetLocation[]>([blankLocation()]);
|
||||
|
||||
function addLocation() {
|
||||
locations.value.push(blankLocation());
|
||||
}
|
||||
function removeLocation(i: number) {
|
||||
locations.value.splice(i, 1);
|
||||
}
|
||||
const tagsText = ref("");
|
||||
const projectId = ref<number | null>(null);
|
||||
const systemIds = ref<number[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const loadError = ref<string | null>(null);
|
||||
const nameRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Systems belong to a project, so the picker only has anything to offer once
|
||||
// one is chosen — and it repopulates when the project changes.
|
||||
const projectSystems = computed(() =>
|
||||
projectId.value ? (systemsStore.systemsByProject[projectId.value] ?? []) : [],
|
||||
);
|
||||
|
||||
async function loadSystems() {
|
||||
if (!projectId.value) return;
|
||||
try {
|
||||
await systemsStore.fetchSystems(projectId.value);
|
||||
} catch {
|
||||
/* non-fatal — the systems picker just won't populate */
|
||||
}
|
||||
}
|
||||
|
||||
watch(projectId, (next, prev) => {
|
||||
// Moving to another project invalidates system ids from the old one.
|
||||
if (prev !== null && next !== prev) systemIds.value = [];
|
||||
loadSystems();
|
||||
});
|
||||
|
||||
const canSave = computed(
|
||||
() => !!form.value.name.trim() && !!form.value.code.trim() && !saving.value,
|
||||
);
|
||||
|
||||
// A near-duplicate blocked on create: the same reusable thing is already
|
||||
// recorded. Recording it twice is what merge then has to undo, so the editor
|
||||
// offers the existing record before it offers to save anyway.
|
||||
interface DuplicateHit {
|
||||
existing_id: number;
|
||||
existing_title: string;
|
||||
}
|
||||
const duplicate = ref<DuplicateHit | null>(null);
|
||||
const forceCreate = ref(false);
|
||||
|
||||
function duplicateFrom(err: unknown): DuplicateHit | null {
|
||||
if (!(err instanceof ApiError) || err.status !== 409) return null;
|
||||
const body = err.body as Record<string, unknown>;
|
||||
if (!body.duplicate) return null;
|
||||
return {
|
||||
existing_id: Number(body.existing_id),
|
||||
existing_title: String(body.existing_title ?? "an existing snippet"),
|
||||
};
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!isEditing.value) {
|
||||
// Recording from a project page carries the project through, so the snippet
|
||||
// lands where the work is instead of unfiled.
|
||||
if (route.query.projectId) {
|
||||
projectId.value = Number(route.query.projectId);
|
||||
await loadSystems();
|
||||
}
|
||||
await nextTick();
|
||||
nameRef.value?.focus();
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
loadError.value = null;
|
||||
try {
|
||||
const s = await getSnippet(editId.value!);
|
||||
const f = s.snippet;
|
||||
form.value = {
|
||||
name: f.name,
|
||||
code: f.code,
|
||||
language: f.language,
|
||||
signature: f.signature,
|
||||
when_to_use: f.when_to_use,
|
||||
};
|
||||
locations.value = f.locations?.length
|
||||
? f.locations.map((l) => ({ ...l }))
|
||||
: [blankLocation()];
|
||||
// The stored tag list carries language + "snippet" markers, which the
|
||||
// backend re-derives on save — show only the caller's extra tags here.
|
||||
tagsText.value = s.tags
|
||||
.filter((t) => t !== "snippet" && t !== f.language)
|
||||
.join(", ");
|
||||
projectId.value = s.project_id ?? null;
|
||||
systemIds.value = (s.systems ?? []).map((sys) => sys.id);
|
||||
await loadSystems();
|
||||
} catch {
|
||||
loadError.value = "Couldn't load this snippet to edit.";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
function parseTags(): string[] {
|
||||
return tagsText.value
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function cleanLocations(): SnippetLocation[] {
|
||||
return locations.value
|
||||
.map((l) => ({ repo: l.repo.trim(), path: l.path.trim(), symbol: l.symbol.trim() }))
|
||||
.filter((l) => l.repo || l.path || l.symbol);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value) return;
|
||||
saving.value = true;
|
||||
const payload: SnippetInput = {
|
||||
name: form.value.name.trim(),
|
||||
code: form.value.code,
|
||||
language: form.value.language.trim(),
|
||||
signature: form.value.signature.trim(),
|
||||
when_to_use: form.value.when_to_use.trim(),
|
||||
locations: cleanLocations(),
|
||||
tags: parseTags(),
|
||||
project_id: projectId.value,
|
||||
system_ids: systemIds.value,
|
||||
};
|
||||
if (forceCreate.value) payload.force = true;
|
||||
try {
|
||||
const result = isEditing.value
|
||||
? await updateSnippet(editId.value!, payload)
|
||||
: await createSnippet(payload);
|
||||
toast.show(isEditing.value ? "Snippet saved" : "Snippet created");
|
||||
router.push(`/snippets/${result.id}`);
|
||||
} catch (err) {
|
||||
// A blocked near-duplicate isn't a failure — it's the record telling you
|
||||
// this already exists. Offer the existing one rather than a red toast.
|
||||
const dup = duplicateFrom(err);
|
||||
if (dup) {
|
||||
duplicate.value = dup;
|
||||
saving.value = false;
|
||||
return;
|
||||
}
|
||||
toast.show("Failed to save snippet", "error");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAnyway() {
|
||||
duplicate.value = null;
|
||||
forceCreate.value = true;
|
||||
await save();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (isEditing.value) router.push(`/snippets/${editId.value}`);
|
||||
else router.push("/snippets");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="snippet-editor" @keydown.ctrl.s.prevent="save" @keydown.meta.s.prevent="save">
|
||||
<router-link :to="isEditing ? `/snippets/${editId}` : '/snippets'" class="back-link">
|
||||
← {{ isEditing ? "Back to snippet" : "Snippets" }}
|
||||
</router-link>
|
||||
|
||||
<h1>{{ isEditing ? "Edit snippet" : "New snippet" }}</h1>
|
||||
|
||||
<div v-if="loading" class="state-msg">Loading…</div>
|
||||
<p v-else-if="loadError" class="error-msg">{{ loadError }}</p>
|
||||
|
||||
<form v-else class="form" @submit.prevent="save">
|
||||
<div class="field">
|
||||
<label for="sn-name">Name <span class="required">*</span></label>
|
||||
<input
|
||||
id="sn-name"
|
||||
ref="nameRef"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="input mono"
|
||||
placeholder="useDebouncedRef"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="sn-when">When to reach for it</label>
|
||||
<input
|
||||
id="sn-when"
|
||||
v-model="form.when_to_use"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Debounce a reactive ref that updates too often"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
<p class="hint">Shown in the recall menu — keep it sharp.</p>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="sn-lang">Language</label>
|
||||
<input
|
||||
id="sn-lang"
|
||||
v-model="form.language"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="typescript"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="sn-sig">Signature</label>
|
||||
<input
|
||||
id="sn-sig"
|
||||
v-model="form.signature"
|
||||
type="text"
|
||||
class="input mono"
|
||||
placeholder="useDebouncedRef(value, ms)"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset class="location-set">
|
||||
<legend>
|
||||
Locations
|
||||
<span class="hint-inline">— where the reference implementation(s) live; a merged snippet keeps every call site</span>
|
||||
</legend>
|
||||
<div v-for="(loc, i) in locations" :key="i" class="loc-row">
|
||||
<input v-model="loc.repo" type="text" class="input mono" placeholder="repo" aria-label="Repo" @keydown.escape="cancel" />
|
||||
<input v-model="loc.path" type="text" class="input mono" placeholder="path" aria-label="Path" @keydown.escape="cancel" />
|
||||
<input v-model="loc.symbol" type="text" class="input mono" placeholder="symbol" aria-label="Symbol" @keydown.escape="cancel" />
|
||||
<button
|
||||
type="button"
|
||||
class="loc-remove"
|
||||
aria-label="Remove location"
|
||||
title="Remove location"
|
||||
@click="removeLocation(i)"
|
||||
>×</button>
|
||||
</div>
|
||||
<button type="button" class="loc-add" @click="addLocation">+ Add location</button>
|
||||
</fieldset>
|
||||
|
||||
<div class="field">
|
||||
<label for="sn-code">Code <span class="required">*</span></label>
|
||||
<textarea
|
||||
id="sn-code"
|
||||
v-model="form.code"
|
||||
class="input mono code-area"
|
||||
rows="14"
|
||||
spellcheck="false"
|
||||
placeholder="Paste the reusable implementation…"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="sn-tags">Tags</label>
|
||||
<input
|
||||
id="sn-tags"
|
||||
v-model="tagsText"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="composable, ui (comma-separated)"
|
||||
@keydown.escape="cancel"
|
||||
/>
|
||||
<p class="hint">Extra tags. Language and “snippet” are added automatically.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="sn-project">Project</label>
|
||||
<ProjectSelector id="sn-project" v-model="projectId" />
|
||||
<p class="hint">
|
||||
Snippets surface proactively within their project; search reaches across all of them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="projectId" class="field">
|
||||
<span class="field-label">Systems</span>
|
||||
<div v-if="projectSystems.length" class="systems">
|
||||
<label v-for="s in projectSystems" :key="s.id" class="system-opt">
|
||||
<input type="checkbox" :value="s.id" v-model="systemIds" />
|
||||
<span>{{ s.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-else class="hint">No systems in this project yet.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="duplicate" class="duplicate" role="alert">
|
||||
<p class="duplicate-title">This may already be recorded</p>
|
||||
<p class="duplicate-body">
|
||||
A similar snippet exists —
|
||||
<router-link :to="`/snippets/${duplicate.existing_id}`">
|
||||
{{ duplicate.existing_title }}
|
||||
</router-link
|
||||
>. Reuse or edit that one rather than keeping two copies of the same thing.
|
||||
</p>
|
||||
<div class="duplicate-actions">
|
||||
<button type="button" class="btn-secondary" @click="saveAnyway">
|
||||
Record it anyway
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-secondary" @click="cancel">Cancel</button>
|
||||
<button type="submit" class="btn-primary" :disabled="!canSave">
|
||||
{{ saving ? "Saving…" : isEditing ? "Save changes" : "Create snippet" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.snippet-editor {
|
||||
max-width: 760px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 var(--page-padding-x);
|
||||
overflow-x: clip;
|
||||
}
|
||||
.snippet-editor h1 {
|
||||
margin: 0 0 1.25rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.back-link:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.state-msg {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.error-msg {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
.field-row.three {
|
||||
grid-template-columns: 1fr 1.4fr 1fr;
|
||||
}
|
||||
.field label,
|
||||
.location-set legend {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.required {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0.1rem 0 0;
|
||||
}
|
||||
.hint-inline {
|
||||
font-weight: 400;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 0.5rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.code-area {
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
overflow-wrap: normal;
|
||||
overflow-x: auto;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.location-set {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.85rem 1rem 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
.location-set legend {
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
.loc-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr 1fr auto;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.loc-remove {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.loc-remove:hover {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.loc-add {
|
||||
margin-top: 0.15rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.loc-add:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.loc-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.systems {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.system-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.system-opt input {
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.duplicate {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--color-warning);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.duplicate-title {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.duplicate-body {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.duplicate-body a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.duplicate-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.field-row,
|
||||
.field-row.three {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,9 @@ import { useTagSuggestions } from "@/composables/useTagSuggestions";
|
||||
import { useFloatingAssist } from "@/composables/useFloatingAssist";
|
||||
import { apiPost, apiGet, apiPatch } from "@/api/client";
|
||||
import type { TaskStatus, TaskPriority } from "@/types/task";
|
||||
import type { TaskKind } from "@/types/note";
|
||||
import { useSystemsStore } from "@/stores/systems";
|
||||
import type { System } from "@/api/systems";
|
||||
import type { Note } from "@/types/note";
|
||||
import type { Editor } from "@tiptap/vue-3";
|
||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||
@@ -31,6 +34,7 @@ import { Trash2 } from "lucide-vue-next";
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useTasksStore();
|
||||
const systemsStore = useSystemsStore();
|
||||
const notesStore = useNotesStore();
|
||||
const toast = useToastStore();
|
||||
|
||||
@@ -41,6 +45,8 @@ const consolidatedAt = ref<string | null>(null);
|
||||
const tags = ref<string[]>([]);
|
||||
const status = ref<TaskStatus>("todo");
|
||||
const priority = ref<TaskPriority>("none");
|
||||
const kind = ref<TaskKind>("work");
|
||||
const systemIds = ref<number[]>([]);
|
||||
const dueDate = ref("");
|
||||
const projectId = ref<number | null>(null);
|
||||
const milestoneId = ref<number | null>(null);
|
||||
@@ -201,6 +207,8 @@ let savedDueDate = "";
|
||||
let savedProjectId: number | null = null;
|
||||
let savedMilestoneId: number | null = null;
|
||||
let savedParentId: number | null = null;
|
||||
let savedKind: TaskKind = "work";
|
||||
let savedSystemIds: number[] = [];
|
||||
|
||||
function markDirty() {
|
||||
dirty.value =
|
||||
@@ -213,7 +221,22 @@ function markDirty() {
|
||||
dueDate.value !== savedDueDate ||
|
||||
projectId.value !== savedProjectId ||
|
||||
milestoneId.value !== savedMilestoneId ||
|
||||
parentId.value !== savedParentId;
|
||||
parentId.value !== savedParentId ||
|
||||
kind.value !== savedKind ||
|
||||
JSON.stringify(systemIds.value) !== JSON.stringify(savedSystemIds);
|
||||
}
|
||||
|
||||
const projectSystems = computed<System[]>(() =>
|
||||
projectId.value ? (systemsStore.systemsByProject[projectId.value] ?? []) : [],
|
||||
);
|
||||
|
||||
async function loadSystems() {
|
||||
if (!projectId.value) return;
|
||||
try {
|
||||
await systemsStore.fetchSystems(projectId.value);
|
||||
} catch {
|
||||
/* non-fatal — the systems picker just won't populate */
|
||||
}
|
||||
}
|
||||
|
||||
function onBodyUpdate(newVal: string) {
|
||||
@@ -288,6 +311,8 @@ onMounted(async () => {
|
||||
const taskRec = store.currentTask as Record<string, unknown>;
|
||||
projectId.value = (taskRec.project_id as number | null) ?? null;
|
||||
milestoneId.value = (taskRec.milestone_id as number | null) ?? null;
|
||||
kind.value = (taskRec.task_kind as TaskKind) ?? "work";
|
||||
systemIds.value = ((taskRec.systems as Array<{ id: number }> | undefined) ?? []).map((s) => s.id);
|
||||
parentId.value = (taskRec.parent_id as number | null) ?? null;
|
||||
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
|
||||
parentSearchQuery.value = parentTitle.value;
|
||||
@@ -305,6 +330,8 @@ onMounted(async () => {
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedParentId = parentId.value;
|
||||
savedKind = kind.value;
|
||||
savedSystemIds = [...systemIds.value];
|
||||
// Start in preview mode only if the task already has body content
|
||||
showPreview.value = body.value.trim().length > 0;
|
||||
}
|
||||
@@ -315,6 +342,7 @@ onMounted(async () => {
|
||||
if (route.query.milestoneId) milestoneId.value = Number(route.query.milestoneId);
|
||||
if (route.query.parentId) parentId.value = Number(route.query.parentId);
|
||||
}
|
||||
loadSystems();
|
||||
});
|
||||
|
||||
async function save() {
|
||||
@@ -333,6 +361,8 @@ async function save() {
|
||||
milestone_id: milestoneId.value,
|
||||
parent_id: parentId.value,
|
||||
recurrence_rule: recurrenceRule.value,
|
||||
kind: kind.value,
|
||||
system_ids: systemIds.value,
|
||||
};
|
||||
if (isEditing.value) {
|
||||
await store.updateTask(taskId.value!, data);
|
||||
@@ -346,6 +376,8 @@ async function save() {
|
||||
savedProjectId = projectId.value;
|
||||
savedMilestoneId = milestoneId.value;
|
||||
savedParentId = parentId.value;
|
||||
savedKind = kind.value;
|
||||
savedSystemIds = [...systemIds.value];
|
||||
dirty.value = false;
|
||||
toast.show("Task saved");
|
||||
router.push(`/tasks/${taskId.value}`);
|
||||
@@ -543,6 +575,16 @@ useEditorGuards(dirty, save);
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sb-field">
|
||||
<label class="sb-label">Kind</label>
|
||||
<select v-model="kind" @change="markDirty" class="sb-select">
|
||||
<option value="work">Work</option>
|
||||
<option value="issue">Issue</option>
|
||||
<!-- 'plan' is retired (plans are milestones via start_planning);
|
||||
offered only so legacy plan-tasks display their kind. -->
|
||||
<option v-if="kind === 'plan'" value="plan">Plan (legacy)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="startedAt || completedAt" class="sb-timestamps">
|
||||
<div v-if="startedAt" class="sb-timestamp">
|
||||
<span class="sb-ts-label">Started</span>
|
||||
@@ -578,6 +620,16 @@ useEditorGuards(dirty, save);
|
||||
<label class="sb-label">Milestone</label>
|
||||
<MilestoneSelector :projectId="projectId" v-model="milestoneId" @update:modelValue="markDirty" />
|
||||
</div>
|
||||
<div v-if="projectId" class="sb-field">
|
||||
<label class="sb-label">Systems</label>
|
||||
<div v-if="projectSystems.length" class="sb-systems">
|
||||
<label v-for="s in projectSystems" :key="s.id" class="sb-system-opt">
|
||||
<input type="checkbox" :value="s.id" v-model="systemIds" @change="markDirty" />
|
||||
<span>{{ s.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-else class="sb-systems-empty">No systems in this project yet.</p>
|
||||
</div>
|
||||
|
||||
<!-- Parent task -->
|
||||
<div class="sb-field">
|
||||
@@ -593,7 +645,7 @@ useEditorGuards(dirty, save);
|
||||
@focus="onParentFocus"
|
||||
@blur="hideParentDropdown"
|
||||
/>
|
||||
<button v-if="parentId" class="btn-clear-parent" @click="clearParentTask" title="Clear">×</button>
|
||||
<button v-if="parentId" class="btn-text btn-clear-parent" @click="clearParentTask" title="Clear">×</button>
|
||||
</div>
|
||||
<div v-if="showParentDropdown" class="parent-dropdown">
|
||||
<div v-if="parentSearchLoading" class="parent-dropdown-item parent-empty">Searching...</div>
|
||||
@@ -614,7 +666,7 @@ useEditorGuards(dirty, save);
|
||||
<div v-if="isEditing" class="subtasks-section">
|
||||
<div class="subtasks-header">
|
||||
<span class="subtasks-label">Sub-tasks</span>
|
||||
<button class="btn-add-subtask" @click="addingSubTask = !addingSubTask">+ Add</button>
|
||||
<button class="btn-text" @click="addingSubTask = !addingSubTask">+ Add</button>
|
||||
</div>
|
||||
<div v-if="subTasksLoading" class="subtasks-loading">Loading...</div>
|
||||
<template v-else>
|
||||
@@ -634,8 +686,8 @@ useEditorGuards(dirty, save);
|
||||
@keydown.escape="addingSubTask = false; newSubTaskTitle = ''"
|
||||
autofocus
|
||||
/>
|
||||
<button class="btn-subtask-confirm" @click="createSubTask" :disabled="!newSubTaskTitle.trim()">Add</button>
|
||||
<button class="btn-subtask-cancel" @click="addingSubTask = false; newSubTaskTitle = ''">Cancel</button>
|
||||
<button class="btn-primary btn-compact" @click="createSubTask" :disabled="!newSubTaskTitle.trim()">Add</button>
|
||||
<button class="btn-ghost btn-compact" @click="addingSubTask = false; newSubTaskTitle = ''">Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -822,7 +874,7 @@ useEditorGuards(dirty, save);
|
||||
padding: 0 0.2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-clear-parent:hover { color: var(--color-danger, #e74c3c); }
|
||||
.btn-clear-parent:hover { color: var(--color-danger); }
|
||||
.parent-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
@@ -869,23 +921,6 @@ useEditorGuards(dirty, save);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.btn-add-subtask {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
padding: 0.1rem 0.2rem;
|
||||
}
|
||||
.btn-add-subtask:hover { opacity: 0.8; }
|
||||
.subtasks-loading { font-size: 0.78rem; color: var(--color-text-muted); }
|
||||
.subtask-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
.subtask-checkbox { flex-shrink: 0; cursor: pointer; }
|
||||
.subtask-title {
|
||||
font-size: 0.83rem;
|
||||
@@ -916,33 +951,6 @@ useEditorGuards(dirty, save);
|
||||
font-family: inherit;
|
||||
}
|
||||
.subtask-input:focus { outline: none; border-color: var(--color-primary); }
|
||||
.btn-subtask-confirm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-subtask-confirm:disabled { opacity: 0.5; cursor: default; }
|
||||
.btn-subtask-cancel {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Streaming preview */
|
||||
.stream-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.stream-preview {
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -955,6 +963,12 @@ useEditorGuards(dirty, save);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Systems multi-select (in sidebar) */
|
||||
.sb-systems { display: flex; flex-direction: column; gap: 0.25rem; max-height: 160px; overflow-y: auto; }
|
||||
.sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--color-text); cursor: pointer; }
|
||||
.sb-system-opt input { accent-color: var(--color-primary); cursor: pointer; }
|
||||
.sb-systems-empty { margin: 0; font-size: 0.8rem; color: var(--color-text-muted); }
|
||||
|
||||
/* Writing Assistant section (in sidebar) */
|
||||
.assist-section {
|
||||
display: flex;
|
||||
@@ -1023,13 +1037,13 @@ useEditorGuards(dirty, save);
|
||||
margin: 0.5rem 0 0.25rem;
|
||||
}
|
||||
.task-goal-label {
|
||||
font-family: var(--font-display, "Fraunces", serif);
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.task-goal-input {
|
||||
width: 100%;
|
||||
@@ -1039,14 +1053,14 @@ useEditorGuards(dirty, save);
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text, inherit);
|
||||
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
|
||||
border-radius: var(--radius-md, 8px);
|
||||
color: var(--color-text);
|
||||
background: var(--color-input-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.task-goal-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Auto-summary banner + re-consolidate button ─────────────────────────── */
|
||||
@@ -1058,33 +1072,13 @@ useEditorGuards(dirty, save);
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
|
||||
color: var(--color-text-muted);
|
||||
background: rgba(99, 102, 241, 0.06);
|
||||
border-left: 2px solid var(--color-primary, #6366f1);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border-left: 2px solid var(--color-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.auto-summary-banner-editor .auto-summary-icon {
|
||||
color: var(--color-primary, #6366f1);
|
||||
color: var(--color-primary);
|
||||
font-style: normal;
|
||||
}
|
||||
.btn-reconsolidate {
|
||||
margin-left: auto;
|
||||
padding: 0.25rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
font-style: normal;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
.btn-reconsolidate:hover:not(:disabled) {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
}
|
||||
.btn-reconsolidate:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: progress;
|
||||
}
|
||||
</style>
|
||||
@@ -263,29 +263,29 @@ const subTaskProgress = computed(() => {
|
||||
<div class="toolbar">
|
||||
<router-link
|
||||
:to="store.currentTask.project_id ? `/projects/${store.currentTask.project_id}` : '/tasks'"
|
||||
class="btn-back"
|
||||
class="btn-ghost"
|
||||
>{{ store.currentTask.project_id ? "← Project" : "← Tasks" }}</router-link>
|
||||
<router-link
|
||||
:to="`/tasks/${store.currentTask.id}/edit`"
|
||||
class="btn-edit"
|
||||
class="btn-primary"
|
||||
>
|
||||
Edit
|
||||
</router-link>
|
||||
<button
|
||||
v-if="advanceLabel"
|
||||
class="btn-advance"
|
||||
class="btn-primary"
|
||||
@click="advanceStatus"
|
||||
>
|
||||
{{ advanceLabel }}
|
||||
</button>
|
||||
<button
|
||||
class="btn-convert"
|
||||
class="btn-secondary btn-compact"
|
||||
@click="convertToNote"
|
||||
:disabled="converting"
|
||||
>
|
||||
{{ converting ? "Converting..." : "Convert to Note" }}
|
||||
</button>
|
||||
<button class="btn-share" @click="showShare = true">Share</button>
|
||||
<button class="btn-secondary btn-compact" @click="showShare = true">Share</button>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb: parent task → project → milestone -->
|
||||
@@ -475,83 +475,6 @@ const subTaskProgress = computed(() => {
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.btn-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.btn-back:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
/* Edit + Advance: Moss action-primary — both are "operating the software"
|
||||
workflow actions, not brand moments. */
|
||||
.btn-edit,
|
||||
.btn-advance {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.45rem 1.1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-action-primary);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-edit:hover,
|
||||
.btn-advance:hover {
|
||||
background: var(--color-action-primary-hover);
|
||||
color: #fff;
|
||||
}
|
||||
/* Convert + Share: Bronze action-secondary — alternate paths */
|
||||
.btn-convert {
|
||||
margin-left: auto;
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-action-secondary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-convert:hover { background: var(--color-action-secondary-hover); }
|
||||
.btn-convert:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.btn-share {
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-action-secondary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
||||
|
||||
.task-title {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-size: 2rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
@@ -638,7 +561,7 @@ const subTaskProgress = computed(() => {
|
||||
}
|
||||
.subtasks-fill {
|
||||
height: 100%;
|
||||
background: var(--color-status-done, #22c55e);
|
||||
background: var(--color-status-done);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
@@ -679,13 +602,13 @@ const subTaskProgress = computed(() => {
|
||||
border: 2px solid var(--color-text-muted);
|
||||
}
|
||||
.dot-in-progress {
|
||||
background: var(--color-status-in-progress, #3b82f6);
|
||||
background: var(--color-status-in-progress);
|
||||
}
|
||||
.dot-done {
|
||||
background: var(--color-status-done, #22c55e);
|
||||
background: var(--color-status-done);
|
||||
}
|
||||
.dot-cancelled {
|
||||
background: var(--color-text-muted, #6b7280);
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
.sub-title {
|
||||
flex: 1;
|
||||
@@ -826,26 +749,26 @@ const subTaskProgress = computed(() => {
|
||||
|
||||
/* ── Goal block + auto-summary banner ─────────────────────────────────────── */
|
||||
.task-goal-display {
|
||||
border-left: 2px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
||||
border-left: 2px solid var(--color-border);
|
||||
padding: 0.4rem 0 0.4rem 0.9rem;
|
||||
margin: 0.75rem 0 1.25rem;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.goal-label {
|
||||
font-family: var(--font-display, "Fraunces", serif);
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.goal-text {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text, inherit);
|
||||
color: var(--color-text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.auto-summary-banner {
|
||||
@@ -854,11 +777,11 @@ const subTaskProgress = computed(() => {
|
||||
gap: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.auto-summary-icon {
|
||||
color: var(--color-primary, #6366f1);
|
||||
color: var(--color-primary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -25,7 +25,7 @@ onMounted(() => store.fetchTrash());
|
||||
<h1>Trash</h1>
|
||||
<button
|
||||
v-if="store.batches.length"
|
||||
class="btn-empty"
|
||||
class="btn-ghost btn-compact"
|
||||
@click="empty"
|
||||
>Empty trash</button>
|
||||
</header>
|
||||
@@ -52,8 +52,8 @@ onMounted(() => store.fetchTrash());
|
||||
</div>
|
||||
</div>
|
||||
<div class="batch-actions">
|
||||
<button class="btn-restore" @click="store.restore(b.batch_id)">Restore</button>
|
||||
<button class="btn-purge" @click="purge(b.batch_id)">Delete permanently</button>
|
||||
<button class="btn-ghost btn-compact btn-restore" @click="store.restore(b.batch_id)">Restore</button>
|
||||
<button class="btn-ghost btn-compact btn-purge" @click="purge(b.batch_id)">Delete permanently</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -64,25 +64,11 @@ onMounted(() => store.fetchTrash());
|
||||
.trash-page { max-width: 900px; margin: 0 auto; padding: 1.5rem; }
|
||||
.trash-header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.trash-header h1 { font-family: Fraunces, serif; font-style: italic; margin: 0; }
|
||||
.btn-empty {
|
||||
background: none; border: 1px solid var(--color-border, #2a2a2e);
|
||||
color: inherit; border-radius: 6px; padding: 0.4rem 0.8rem; cursor: pointer;
|
||||
}
|
||||
.btn-empty:hover { border-color: var(--color-danger, #ef4444); color: var(--color-danger, #ef4444); }
|
||||
.trash-note { opacity: 0.7; font-size: 0.9em; margin: 0.5rem 0 1.5rem; }
|
||||
.trash-loading, .trash-empty { opacity: 0.6; font-style: italic; padding: 2rem 0; }
|
||||
.batch-list { list-style: none; padding: 0; margin: 0; }
|
||||
.batch {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 1rem; padding: 0.85rem 1rem; margin-bottom: 0.5rem;
|
||||
background: var(--color-surface, #18181b); border-radius: 8px;
|
||||
border-left: 2px solid var(--color-border, #2a2a2e);
|
||||
}
|
||||
.batch-summary { font-weight: 500; }
|
||||
.batch-count { opacity: 0.6; font-weight: 400; font-size: 0.9em; margin-left: 0.35rem; }
|
||||
.batch-meta { font-size: 0.82em; opacity: 0.6; margin-top: 0.25rem; }
|
||||
.batch-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
|
||||
.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--color-border, #2a2a2e); background: none; color: inherit; }
|
||||
.btn-restore:hover { border-color: var(--color-primary, #6366f1); color: var(--color-primary, #6366f1); }
|
||||
.btn-purge:hover { border-color: var(--color-danger, #ef4444); color: var(--color-danger, #ef4444); }
|
||||
.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--color-border); background: none; color: inherit; }
|
||||
.btn-restore:hover { border-color: var(--color-action-primary); color: var(--color-action-primary); }
|
||||
.btn-purge:hover { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
|
||||
</style>
|
||||
|
||||
@@ -167,7 +167,7 @@ function formatDate(iso: string): string {
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn-toggle"
|
||||
class="btn-primary btn-toggle"
|
||||
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
|
||||
@click="toggleRegistration"
|
||||
:disabled="toggling"
|
||||
@@ -190,7 +190,7 @@ function formatDate(iso: string): string {
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-invite"
|
||||
class="btn-primary"
|
||||
:disabled="sendingInvite || !inviteEmail.trim()"
|
||||
>
|
||||
{{ sendingInvite ? "Sending..." : "Send Invite" }}
|
||||
@@ -216,7 +216,7 @@ function formatDate(iso: string): string {
|
||||
<td class="hide-mobile cell-date">{{ formatDate(inv.expires_at) }}</td>
|
||||
<td class="cell-actions">
|
||||
<button
|
||||
class="btn-delete"
|
||||
class="btn-ghost btn-compact"
|
||||
@click="revokeInvitation(inv.id)"
|
||||
:disabled="revokingId !== null"
|
||||
>
|
||||
@@ -262,17 +262,17 @@ function formatDate(iso: string): string {
|
||||
</template>
|
||||
<template v-else-if="confirmDeleteId === u.id">
|
||||
<button
|
||||
class="btn-confirm-delete"
|
||||
class="btn-danger btn-compact"
|
||||
@click="confirmDelete(u.id)"
|
||||
:disabled="deleting !== null"
|
||||
>
|
||||
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
|
||||
</button>
|
||||
<button class="btn-cancel-delete" @click="cancelDelete">Cancel</button>
|
||||
<button class="btn-ghost btn-compact" @click="cancelDelete">Cancel</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button
|
||||
class="btn-delete"
|
||||
class="btn-ghost btn-compact"
|
||||
@click="confirmDelete(u.id)"
|
||||
:disabled="deleting !== null"
|
||||
>
|
||||
@@ -328,24 +328,6 @@ function formatDate(iso: string): string {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-invite {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-invite:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-invite:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.invite-list {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -380,26 +362,8 @@ function formatDate(iso: string): string {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.btn-toggle {
|
||||
padding: 0.45rem 1rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-toggle:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-toggle-open {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-toggle-open:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
/* The one genuine override: 'close registration' must NOT read as the
|
||||
primary action it sits on. Scoped, so it beats the shared variant. */
|
||||
.btn-toggle-close {
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
@@ -478,54 +442,6 @@ function formatDate(iso: string): string {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.btn-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-delete:hover:not(:disabled) {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
.btn-delete:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-confirm-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.btn-confirm-delete:hover:not(:disabled) {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
.btn-confirm-delete:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-cancel-delete {
|
||||
padding: 0.25rem 0.6rem;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-cancel-delete:hover {
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.registration-row {
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
{
|
||||
"name": "scribe",
|
||||
"description": "Scribe second brain for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, and a set of universal process-skills (brainstorm, debug, TDD, plan, verify). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||
"version": "0.1.0",
|
||||
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||
"version": "0.1.23",
|
||||
"author": { "name": "Bryan Van Deusen" },
|
||||
"mcpServers": "./.mcp.json",
|
||||
"mcpServers": {
|
||||
"scribe": {
|
||||
"type": "http",
|
||||
"url": "${user_config.api_endpoint}/mcp",
|
||||
"headers": { "Authorization": "Bearer ${user_config.api_token}" }
|
||||
}
|
||||
},
|
||||
"userConfig": {
|
||||
"api_endpoint": {
|
||||
"type": "string",
|
||||
@@ -15,11 +21,6 @@
|
||||
"title": "Scribe API key",
|
||||
"description": "An fmcp_ API key from Settings → API Keys (read scope is enough for the session-start hook; write scope to use the tools)",
|
||||
"sensitive": true
|
||||
},
|
||||
"project_id": {
|
||||
"type": "string",
|
||||
"title": "Active project id (optional)",
|
||||
"description": "Numeric Scribe project id to scope the session-start context. Leave blank to inject standing rules only."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-7
@@ -3,12 +3,21 @@
|
||||
Turns a self-hosted [Scribe](https://git.fabledsword.com/bvandeusen/FabledScribe)
|
||||
instance into a first-class Claude Code extension:
|
||||
|
||||
- **MCP tools** over your notes, tasks, projects, milestones, events, typed
|
||||
entities, and rulebook (the `scribe` server).
|
||||
- **MCP tools** over your notes, tasks, projects, milestones, systems, and
|
||||
rulebook (the `scribe` server).
|
||||
- **Session-start push channel** — a `SessionStart` hook injects your always-on
|
||||
rules + active-project context so Scribe surfaces *without being asked*.
|
||||
- **Universal process-skills** — brainstorm, systematic-debugging, TDD,
|
||||
writing-plans, verification, receiving-code-review (replaces superpowers).
|
||||
- **Prior-art recall on writes** — a `PreToolUse` hook on Write/Edit checks the
|
||||
file about to be written against your recorded snippets (what's kept at that
|
||||
path, and what resembles the code) and offers them before the helper is
|
||||
rewritten. Titles only, never blocks the edit.
|
||||
- **Universal process-skills** — using-scribe, writing-plans,
|
||||
systematic-debugging, verification, brainstorming, reusing-code (record and
|
||||
recall reusable code as snippets). Replaces superpowers.
|
||||
- **Your Scribe Processes as skills** — saved Processes are synced into local
|
||||
`~/.claude/skills/scribe-proc-*` stubs that auto-surface by relevance; the
|
||||
stub fetches the live procedure via `get_process`. Refreshed each session and
|
||||
on demand with `/scribe:sync`.
|
||||
|
||||
It is designed so you can uninstall `superpowers` and disable auto-memory and
|
||||
depend on neither.
|
||||
@@ -33,15 +42,25 @@ On install you'll be asked for:
|
||||
|
||||
## What gets wired
|
||||
|
||||
- `.mcp.json` → the `scribe` MCP server at `<base URL>/mcp` (Bearer auth).
|
||||
- `plugin.json` `mcpServers` → the `scribe` MCP server at `<base URL>/mcp` (Bearer auth).
|
||||
- `hooks/hooks.json` → SessionStart hook (`hooks/scribe_session_context.sh`),
|
||||
**fail-open**: if Scribe is unreachable it injects nothing and never blocks
|
||||
the session.
|
||||
- `hooks/hooks.json` → PreToolUse hook on `Write|Edit`
|
||||
(`hooks/scribe_prior_art.sh`) → `GET /api/plugin/prior-art`. Returns
|
||||
`additionalContext` with **no** permission decision, so it can inform the write
|
||||
but never stop it; silent when nothing is recorded, which is most of the time.
|
||||
Toggle in **Settings → Knowledge auto-inject**.
|
||||
- `skills/` → the universal process-skills, surfaced by description match.
|
||||
- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync`
|
||||
command → generate `~/.claude/skills/scribe-proc-*` stubs from your Scribe
|
||||
Processes (via `GET /api/plugin/processes`); also **fail-open**, and pruned to
|
||||
match what exists in Scribe.
|
||||
|
||||
## Notes
|
||||
|
||||
- Set a `version` bump in `.claude-plugin/plugin.json` per release so clients
|
||||
pick up changes.
|
||||
- The session-start hook needs only a **read**-scoped key; the MCP tools need
|
||||
**write** scope to create/update.
|
||||
- The session-start, auto-inject and prior-art hooks need only a **read**-scoped
|
||||
key; the MCP tools need **write** scope to create/update. Every hook is a GET
|
||||
for that reason — a read key cannot POST.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description: Sync your Scribe stored Processes into auto-surfacing local skills
|
||||
allowed-tools: Bash(bash:*), Bash(ls:*)
|
||||
---
|
||||
|
||||
Regenerate the local skill stubs for your Scribe **Processes** so they auto-surface
|
||||
this session. Each Process becomes a `~/.claude/skills/scribe-proc-<slug>/SKILL.md`
|
||||
whose body calls `get_process(<name>)` for the live procedure.
|
||||
|
||||
This normally runs automatically at session start; use it after adding or editing
|
||||
a Process when you want it available immediately (Claude Code live-detects the new
|
||||
skill files within this session — no restart needed).
|
||||
|
||||
Run the bundled sync script, then list what was synced:
|
||||
|
||||
```
|
||||
bash "${CLAUDE_PLUGIN_ROOT}/hooks/scribe_sync_processes.sh" && ls ~/.claude/skills 2>/dev/null | grep '^scribe-proc-' || echo "no Scribe process skills (no Processes, or Scribe unreachable)"
|
||||
```
|
||||
|
||||
Report which `scribe-proc-*` skills are now present.
|
||||
+26
-1
@@ -5,7 +5,32 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "SCRIBE_URL=\"${user_config.api_endpoint}\" SCRIBE_TOKEN=\"${user_config.api_token}\" SCRIBE_PROJECT_ID=\"${user_config.project_id}\" bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_session_context.sh\""
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_session_context.sh\""
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_sync_processes.sh\""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_autoinject.sh\""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/scribe_prior_art.sh\""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# Scribe plugin — UserPromptSubmit push channel (knowledge auto-inject, Path A).
|
||||
#
|
||||
# On each user prompt, asks the operator's Scribe instance for a TITLE-FIRST
|
||||
# awareness hint: the few notes that clear the per-user auto-inject gates
|
||||
# (high-confidence threshold, margin gate, session dedup, top-k). Titles + ids
|
||||
# only — never bodies; the agent calls get_note(id) to pull anything it judges
|
||||
# relevant. Most turns inject nothing.
|
||||
#
|
||||
# Best-effort enrichment ONLY: unlike the SessionStart channel there is no
|
||||
# static floor here. If the instance is unconfigured/unreachable, or anything
|
||||
# fails, the hook stays SILENT and exits 0 — it must never block a prompt.
|
||||
#
|
||||
# Config (same as scribe_session_context.sh), exported to the hook by Claude Code
|
||||
# with the userConfig key UPPERCASED (see #2198 — reading the lowercase spelling
|
||||
# silently disables this hook, and silence is indistinguishable from "nothing
|
||||
# cleared the threshold"):
|
||||
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
||||
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
||||
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
||||
#
|
||||
# Session dedup: each surfaced note id is remembered in a per-session file so a
|
||||
# note is injected at most once per session. Passed back as exclude_ids.
|
||||
set -uo pipefail
|
||||
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
command -v curl >/dev/null 2>&1 || exit 0
|
||||
|
||||
# UserPromptSubmit delivers a JSON event on stdin: { prompt, session_id, cwd, ... }
|
||||
event=$(cat 2>/dev/null || true)
|
||||
prompt=$(printf '%s' "$event" | jq -r '.prompt // empty' 2>/dev/null) || prompt=""
|
||||
session_id=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
|
||||
event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_cwd=""
|
||||
|
||||
# Nothing to retrieve against.
|
||||
[ -n "$prompt" ] || exit 0
|
||||
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
||||
case "$url" in *'${'*) url="" ;; esac
|
||||
case "$token" in *'${'*) token="" ;; esac
|
||||
# Unconfigured install → silent (auto-inject is pure enrichment).
|
||||
[ -n "$url" ] && [ -n "$token" ] || exit 0
|
||||
|
||||
# Cap the query length — a giant prompt makes a giant URL for no extra signal.
|
||||
# `head -c`, not `cut -c1-2000`: cut is line-oriented and caps EACH LINE, so a
|
||||
# long multi-line prompt sailed past the budget entirely. Same defect as the
|
||||
# prior-art hook's code cap; this copy was missed when that one was fixed, and
|
||||
# scripts/check_plugin.py caught it.
|
||||
q=$(printf '%s' "$prompt" | head -c 2000)
|
||||
# `-sRr`, not `-rR`: jq -R reads LINE BY LINE, so a multi-line prompt encoded as
|
||||
# several lines joined by raw newlines and the request died. Single-line prompts
|
||||
# worked, which is why this looked healthy — the long, substantial prompts most
|
||||
# worth retrieving against were exactly the ones silently dropped. -s slurps.
|
||||
q_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || exit 0
|
||||
|
||||
# Resolve the working repo's remote so the server can scope to the bound project.
|
||||
repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
||||
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
||||
repo_q=""
|
||||
if [ -n "$repo" ]; then
|
||||
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
||||
fi
|
||||
|
||||
# Per-session dedup: ids already injected this session are skipped.
|
||||
state_dir="${TMPDIR:-/tmp}/scribe-autoinject"
|
||||
mkdir -p "$state_dir" 2>/dev/null || true
|
||||
idfile=""
|
||||
exclude_q=""
|
||||
if [ -n "$session_id" ]; then
|
||||
# session_id is an opaque token from Claude Code; keep only filename-safe chars.
|
||||
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
||||
idfile="$state_dir/${safe_sid}.ids"
|
||||
if [ -f "$idfile" ]; then
|
||||
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
||||
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
||||
fi
|
||||
fi
|
||||
|
||||
body=$(curl -fsS --max-time 5 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/retrieve?q=${q_enc}${repo_q}${exclude_q}" 2>/dev/null) || exit 0
|
||||
[ -n "$body" ] || exit 0
|
||||
|
||||
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
|
||||
[ -n "$context" ] || exit 0
|
||||
|
||||
# Remember the surfaced ids so they aren't injected again this session.
|
||||
if [ -n "$idfile" ]; then
|
||||
printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
|
||||
fi
|
||||
|
||||
jq -n --arg c "$context" \
|
||||
'{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $c}}'
|
||||
exit 0
|
||||
Executable
+212
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env bash
|
||||
# Scribe plugin — PreToolUse write-path trigger (prior-art recall).
|
||||
#
|
||||
# Auto-inject (scribe_autoinject.sh) fires on the operator's prompt. The moment
|
||||
# reuse is actually lost is later: when the AGENT decides mid-task to write a
|
||||
# helper. This hook fires there — on Write/Edit — and asks the operator's Scribe
|
||||
# instance what prior art is already recorded for the target file: a snippet at
|
||||
# that path or in its directory, plus snippets resembling the code about to be
|
||||
# written. Titles + ids only, never bodies.
|
||||
#
|
||||
# NEVER BLOCKS. It returns `additionalContext` with no `permissionDecision`, so
|
||||
# the write proceeds untouched and Claude sees the note beside the tool result.
|
||||
# Any failure — unconfigured, unreachable, malformed — exits 0 in silence. A
|
||||
# recall aid must not be able to stop the operator's work.
|
||||
#
|
||||
# Config (same as the other hooks), exported to the hook by Claude Code with the
|
||||
# userConfig key UPPERCASED (see #2198 — the lowercase spelling reads as empty
|
||||
# and this hook then exits 0 in silence, looking exactly like "no prior art"):
|
||||
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
||||
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
||||
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
||||
set -uo pipefail
|
||||
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
command -v curl >/dev/null 2>&1 || exit 0
|
||||
|
||||
# PreToolUse delivers { session_id, cwd, tool_name, tool_input: {...}, ... }
|
||||
event=$(cat 2>/dev/null || true)
|
||||
file_path=$(printf '%s' "$event" | jq -r '.tool_input.file_path // empty' 2>/dev/null) || exit 0
|
||||
session_id=$(printf '%s' "$event" | jq -r '.session_id // empty' 2>/dev/null) || session_id=""
|
||||
event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_cwd=""
|
||||
|
||||
[ -n "$file_path" ] || exit 0
|
||||
|
||||
# The code about to be written. Write and Edit name this field differently, and
|
||||
# the names have changed across Claude Code versions — take whichever is present
|
||||
# rather than betting on one shape.
|
||||
code=$(printf '%s' "$event" | jq -r '
|
||||
.tool_input.content // .tool_input.file_content //
|
||||
.tool_input.new_string // .tool_input.new_str // empty' 2>/dev/null) || code=""
|
||||
|
||||
# Skip formats that hold prose or data rather than reusable code. Purely to
|
||||
# avoid a pointless round-trip — the server would return nothing for these
|
||||
# anyway. Config formats are NOT skipped: a CI workflow or a compose file is
|
||||
# often exactly the thing worth reusing.
|
||||
case "$file_path" in
|
||||
*.md|*.mdx|*.txt|*.rst|*.json|*.lock|*.log|*.csv|*.tsv|*.svg|*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf)
|
||||
exit 0 ;;
|
||||
esac
|
||||
|
||||
# Snippet locations are recorded repo-relative, so send a repo-relative path —
|
||||
# an absolute one would simply match nothing. Resolved BEFORE the config gate
|
||||
# because the local arm below needs the repo root and needs no server at all.
|
||||
lookup_dir=$(dirname -- "$file_path" 2>/dev/null || true)
|
||||
[ -d "$lookup_dir" ] || lookup_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
||||
repo_root=$(git -C "$lookup_dir" rev-parse --show-toplevel 2>/dev/null || true)
|
||||
rel_path="$file_path"
|
||||
if [ -n "$repo_root" ]; then
|
||||
case "$file_path" in
|
||||
"$repo_root"/*) rel_path="${file_path#"$repo_root"/}" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ARM 1 — BY NAME, LOCALLY (#2280). Does a definition of this already exist?
|
||||
#
|
||||
# The other two arms ask Scribe what was RECORDED. Scribe has never read a line
|
||||
# of the codebase, so a helper nobody thought to record is invisible to them —
|
||||
# which is how `.btn-primary` came to be defined four times, in four scoped
|
||||
# stylesheets, already diverged. It was never a snippet, so no threshold and no
|
||||
# query rewrite could ever have surfaced it.
|
||||
#
|
||||
# This arm closes that by asking the only question the record cannot answer,
|
||||
# in the only place that can: the hook already runs on the developer's machine,
|
||||
# inside the repo, holding the code about to be written. No index, no storage,
|
||||
# no staleness, and no server — it deliberately runs even on an install that
|
||||
# has never configured Scribe.
|
||||
#
|
||||
# Definition-shaped patterns only. Grepping for bare occurrences would match
|
||||
# every CALL site and drown the real finding — and a hint that is mostly noise
|
||||
# is one people learn to skip, which is worse than none.
|
||||
# ---------------------------------------------------------------------------
|
||||
local_lines=""
|
||||
if [ -n "$repo_root" ] && [ -n "$code" ]; then
|
||||
# kind<TAB>name for each thing this payload DEFINES.
|
||||
names=$(printf '%s' "$code" | awk '
|
||||
match($0, /^[[:space:]]*\.[A-Za-z][A-Za-z0-9_-]*[[:space:]]*[,{]/) {
|
||||
t = $0; sub(/^[[:space:]]*\./, "", t); sub(/[[:space:]]*[,{].*$/, "", t);
|
||||
if (t != "") print "css\t" t; next }
|
||||
match($0, /^[[:space:]]*(export[[:space:]]+)?(default[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) {
|
||||
t = $0; sub(/^.*function[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t);
|
||||
if (t != "") print "sym\t" t; next }
|
||||
match($0, /^[[:space:]]*(export[[:space:]]+)?class[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*/) {
|
||||
t = $0; sub(/^.*class[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_$].*$/, "", t);
|
||||
if (t != "") print "sym\t" t; next }
|
||||
match($0, /^[[:space:]]*(async[[:space:]]+)?def[[:space:]]+[A-Za-z_][A-Za-z0-9_]*/) {
|
||||
t = $0; sub(/^.*def[[:space:]]+/, "", t); sub(/[^A-Za-z0-9_].*$/, "", t);
|
||||
if (t != "") print "sym\t" t; next }
|
||||
match($0, /^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?[(<]/) {
|
||||
t = $0; sub(/^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+/, "", t);
|
||||
sub(/[^A-Za-z0-9_$].*$/, "", t);
|
||||
if (t != "") print "sym\t" t; next }
|
||||
' 2>/dev/null | sort -u | head -12) || names=""
|
||||
|
||||
while IFS=$'\t' read -r kind name; do
|
||||
[ -n "${name:-}" ] || continue
|
||||
case "$kind" in
|
||||
css) pat="^[[:space:]]*\.${name}[[:space:]]*[,{]" ;;
|
||||
*) pat="(function|class|def)[[:space:]]+${name}[^A-Za-z0-9_]|(const|let)[[:space:]]+${name}[[:space:]]*=" ;;
|
||||
esac
|
||||
# -I skips binaries; :(exclude) drops the file being written, which would
|
||||
# otherwise always match itself on an Edit.
|
||||
hits=$(git -C "$repo_root" grep -I -l -E -e "$pat" -- . ":(exclude)${rel_path}" 2>/dev/null | head -4) || hits=""
|
||||
[ -n "$hits" ] || continue
|
||||
count=$(printf '%s\n' "$hits" | grep -c . 2>/dev/null || echo 0)
|
||||
label=$([ "$kind" = css ] && printf '.%s' "$name" || printf '%s' "$name")
|
||||
files=$(printf '%s' "$hits" | tr '\n' ' ' | sed 's/ $//')
|
||||
local_lines="${local_lines}> - \`${label}\` is already defined in ${count} other file(s): ${files}"$'\n'
|
||||
done <<< "$names"
|
||||
fi
|
||||
|
||||
local_context=""
|
||||
if [ -n "$local_lines" ]; then
|
||||
local_context="> Already defined elsewhere in this repo — check before adding another copy (\`git grep\` shown; this is a nudge, not a gate):"$'\n'"${local_lines}"
|
||||
fi
|
||||
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
||||
case "$url" in *'${'*) url="" ;; esac
|
||||
case "$token" in *'${'*) token="" ;; esac
|
||||
# Unconfigured install → the recorded-prior-art arms are skipped, but the local
|
||||
# arm above already ran and may have something to say.
|
||||
if [ -z "$url" ] || [ -z "$token" ]; then
|
||||
if [ -n "$local_context" ]; then
|
||||
jq -n --arg c "$local_context" \
|
||||
'{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}'
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Cap the code sent as the semantic query. The embedder truncates at its own
|
||||
# token limit well before this, so a bigger slice buys no extra signal — and the
|
||||
# payload has to stay a GET (a read-scoped API key cannot POST, and every other
|
||||
# plugin hook works with a read key).
|
||||
# `head -c`, not `cut -c1-1200`: cut is line-oriented and caps each line
|
||||
# separately, so a 400-line edit sailed past the "1200 char" budget entirely and
|
||||
# built a URL from the whole payload. head -c caps the total, which is the point.
|
||||
q=$(printf '%s' "$code" | head -c 1200)
|
||||
|
||||
# `-sRr`, not `-rR`: jq -R reads input LINE BY LINE, so a multi-line payload came
|
||||
# back as several separately-encoded lines joined by raw newlines — an invalid
|
||||
# URL that made curl fail, and this hook then exited 0 in silence. -s slurps the
|
||||
# whole input into one string first. Newlines are exactly what code contains, so
|
||||
# this hook could never have worked without it (issue #2198 / #2082).
|
||||
path_enc=$(printf '%s' "$rel_path" | jq -sRr '@uri' 2>/dev/null) || exit 0
|
||||
code_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || code_enc=""
|
||||
|
||||
# Resolve the working repo's remote so the server can scope to the bound project.
|
||||
repo=$(git -C "$lookup_dir" remote get-url origin 2>/dev/null || true)
|
||||
repo_q=""
|
||||
if [ -n "$repo" ]; then
|
||||
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
||||
fi
|
||||
|
||||
# Per-session dedup, in its own file rather than sharing auto-inject's. Each
|
||||
# surface shows a given snippet at most once per session, but they don't silence
|
||||
# each other: a title that flew past in a prompt menu twenty turns ago is
|
||||
# exactly what should reappear at the moment the duplicate is being written.
|
||||
state_dir="${TMPDIR:-/tmp}/scribe-priorart"
|
||||
mkdir -p "$state_dir" 2>/dev/null || true
|
||||
idfile=""
|
||||
exclude_q=""
|
||||
if [ -n "$session_id" ]; then
|
||||
safe_sid=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
|
||||
idfile="$state_dir/${safe_sid}.ids"
|
||||
if [ -f "$idfile" ]; then
|
||||
seen=$(tr '\n' ',' < "$idfile" 2>/dev/null | sed 's/,$//')
|
||||
[ -n "$seen" ] && exclude_q="&exclude_ids=${seen}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# `|| true`, not `|| exit 0`: an unreachable instance must not discard a local
|
||||
# finding that needed no instance to produce.
|
||||
body=$(curl -fsS --max-time 5 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/prior-art?path=${path_enc}&code=${code_enc}${repo_q}${exclude_q}" 2>/dev/null) || body=""
|
||||
|
||||
context=""
|
||||
if [ -n "$body" ]; then
|
||||
context=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || context=""
|
||||
# Remember what was surfaced so it isn't shown again this session.
|
||||
if [ -n "$idfile" ] && [ -n "$context" ]; then
|
||||
printf '%s' "$body" | jq -r '.note_ids[]? // empty' 2>/dev/null >> "$idfile" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Local first. It answers "this already EXISTS", which is a stronger claim than
|
||||
# "this resembles something recorded" — and it is the one the recorded arms are
|
||||
# structurally unable to make.
|
||||
combined="$local_context"
|
||||
if [ -n "$context" ]; then
|
||||
[ -n "$combined" ] && combined="${combined}"$'\n'
|
||||
combined="${combined}${context}"
|
||||
fi
|
||||
[ -n "$combined" ] || exit 0
|
||||
|
||||
# No permissionDecision: this is a nudge, not a gate. The write goes ahead.
|
||||
jq -n --arg c "$combined" \
|
||||
'{hookSpecificOutput: {hookEventName: "PreToolUse", additionalContext: $c}}'
|
||||
exit 0
|
||||
@@ -1,35 +1,140 @@
|
||||
#!/usr/bin/env bash
|
||||
# Scribe plugin — SessionStart push channel.
|
||||
# Scribe plugin — SessionStart push channel (two tiers + compaction re-grounding).
|
||||
#
|
||||
# Curls the operator's Scribe instance for always-on rules + active-project
|
||||
# context and emits it as SessionStart `additionalContext`. Config comes from
|
||||
# plugin userConfig, passed in as env by hooks/hooks.json:
|
||||
# SCRIBE_URL base URL, no trailing slash (user_config.api_endpoint)
|
||||
# SCRIBE_TOKEN fmcp_ API key (user_config.api_token)
|
||||
# SCRIBE_PROJECT_ID optional numeric project id (user_config.project_id)
|
||||
# Tier 1 (STATIC, always fires, no auth, no network): injects a bundled
|
||||
# behavioral mandate (scribe_static_context.md) so a fresh session knows to
|
||||
# reach for Scribe — record work, recall before acting — even when the instance
|
||||
# is unreachable or unconfigured. The static tier is the load-bearing floor that
|
||||
# does not depend on the key or the network.
|
||||
#
|
||||
# FAIL-OPEN: any missing tool/config, network error, or unreachable instance
|
||||
# injects nothing and exits 0. A session must never be blocked by Scribe.
|
||||
# Tier 2 (DYNAMIC, best-effort enrichment): curls the operator's Scribe instance
|
||||
# for always-on rules + active-project context and appends it. Config comes from
|
||||
# the plugin's userConfig, exported to hooks as:
|
||||
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
||||
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
||||
# NOTE THE CASE: Claude Code uppercases the userConfig key when exporting it, so
|
||||
# the `api_token` option arrives as CLAUDE_PLUGIN_OPTION_API_TOKEN. Reading the
|
||||
# lowercase spelling silently yields nothing — that was issue #2198, and it
|
||||
# disabled the dynamic tier, auto-inject, and the write-path trigger at once.
|
||||
# The active project is resolved server-side from the working repo's git remote
|
||||
# (see services/repo_bindings); bind each repo once with the bind_repo MCP tool.
|
||||
#
|
||||
# COMPACTION RE-GROUNDING: this hook is registered matcher-less, so it ALSO
|
||||
# fires after a compaction (SessionStart input `source` == "compact"), when
|
||||
# earlier turns have just been summarized and in-flight state is most at risk.
|
||||
# On that source we lead with a banner telling the model to reload project +
|
||||
# in-flight tasks from Scribe. (PreCompact is the wrong tool here — a host hook
|
||||
# can't make the model flush, and can't know the in-flight task ids; the durable
|
||||
# path is record-as-you-go + this post-compaction reload.)
|
||||
#
|
||||
# IMPORTANT: do NOT pass config via `${user_config.*}` substitution in a
|
||||
# shell-form hooks.json command — Claude Code rejects that outright (splicing a
|
||||
# configured value into a shell command line would let the shell run whatever it
|
||||
# contains). The env vars above are the supported channel; SCRIBE_URL /
|
||||
# SCRIBE_TOKEN override for the settings.json dogfooding path.
|
||||
#
|
||||
# FAIL-OPEN, BUT NOT SILENT: the dynamic tier never blocks a session, and every
|
||||
# way it can come up empty produces a short status line — failed fetch, missing
|
||||
# token, and missing-everything alike. Nothing about the credential path is
|
||||
# allowed to fail quietly; see the #2198 comment at the status block below.
|
||||
set -uo pipefail
|
||||
|
||||
command -v jq >/dev/null 2>&1 || exit 0
|
||||
command -v curl >/dev/null 2>&1 || exit 0
|
||||
command -v jq >/dev/null 2>&1 || exit 0 # needed to emit the JSON envelope safely
|
||||
|
||||
url=${SCRIBE_URL:-}
|
||||
token=${SCRIBE_TOKEN:-}
|
||||
project_id=${SCRIBE_PROJECT_ID:-}
|
||||
[ -n "$url" ] && [ -n "$token" ] || exit 0
|
||||
# `CDPATH= cd` is deliberate, not a typo'd assignment: it runs this one `cd`
|
||||
# with CDPATH empty, so an operator whose CDPATH happens to contain a matching
|
||||
# directory name can't send us somewhere else — and `cd` won't echo the resolved
|
||||
# path into our output. shellcheck can't tell that idiom from `CDPATH=cd`.
|
||||
# shellcheck disable=SC1007
|
||||
here=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) || exit 0
|
||||
|
||||
q=""
|
||||
[ -n "$project_id" ] && q="?project_id=${project_id}"
|
||||
# SessionStart delivers a JSON event on stdin; `source` is startup|resume|compact|clear.
|
||||
event=$(cat 2>/dev/null || true)
|
||||
source=$(printf '%s' "$event" | jq -r '.source // empty' 2>/dev/null) || source=""
|
||||
|
||||
body=$(curl -fsS --max-time 8 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/context${q}" 2>/dev/null) || exit 0
|
||||
out=""
|
||||
# Append $1 to $out, separated by a horizontal rule when $out already has content.
|
||||
append() { if [ -n "$out" ]; then out="${out}"$'\n\n---\n\n'"$1"; else out="$1"; fi; }
|
||||
# Prepend $1 above $out (used for the compaction banner so it's seen first).
|
||||
prepend() { if [ -n "$out" ]; then out="$1"$'\n\n---\n\n'"${out}"; else out="$1"; fi; }
|
||||
|
||||
text=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null) || exit 0
|
||||
[ -n "$text" ] || exit 0
|
||||
# --- Tier 1: static behavioral mandate (always, keyless, networkless) ---
|
||||
[ -f "$here/scribe_static_context.md" ] && out=$(cat "$here/scribe_static_context.md")
|
||||
|
||||
jq -n --arg c "$text" \
|
||||
# --- Which version is actually RUNNING (keyless, networkless) ---
|
||||
#
|
||||
# An install has two halves and only one self-updates:
|
||||
#
|
||||
# marketplaces/…/scribe-plugin/ git clone — pulls on its own
|
||||
# cache/…/scribe/<version>/ what EXECUTES — refreshed only when the
|
||||
# manifest version changes
|
||||
#
|
||||
# So inspecting the clone shows a fix present while the broken copy keeps
|
||||
# running, and the obvious debugging move actively misleads (#2209). Twice, the
|
||||
# only detector was the operator saying "I don't think it updated".
|
||||
#
|
||||
# Naming the running version in every session makes that answerable from the
|
||||
# transcript instead of by archaeology in the cache directory. Deliberately NOT
|
||||
# a server round-trip or a stored per-user record: the state most needing
|
||||
# diagnosis is the one where credentials never arrive, and this line still
|
||||
# appears there.
|
||||
manifest="$here/../.claude-plugin/plugin.json"
|
||||
if [ -f "$manifest" ]; then
|
||||
plugin_version=$(jq -r '.version // empty' "$manifest" 2>/dev/null) || plugin_version=""
|
||||
if [ -n "$plugin_version" ]; then
|
||||
append "> Scribe plugin **v${plugin_version}** is executing in this session. A fix merged after this version has not reached it — the marketplace clone updates on its own, but the cache that runs only refreshes when the manifest version changes."
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Tier 2: dynamic rules + active-project context (best-effort) ---
|
||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||
|
||||
# Guard against an unexpanded `${...}` placeholder reaching us as a literal — it
|
||||
# would otherwise be sent as a garbage Bearer token and 401. Treat as unset.
|
||||
case "$url" in *'${'*) url="" ;; esac
|
||||
case "$token" in *'${'*) token="" ;; esac
|
||||
|
||||
dyn=""
|
||||
status=""
|
||||
if [ -n "$url" ] && [ -n "$token" ] && command -v curl >/dev/null 2>&1; then
|
||||
# Resolve the working repo's remote so the server can map it to a project.
|
||||
repo_dir=${CLAUDE_PROJECT_DIR:-$PWD}
|
||||
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
||||
q=""
|
||||
if [ -n "$repo" ]; then
|
||||
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||
[ -n "$enc" ] && q="?repo=${enc}"
|
||||
fi
|
||||
body=$(curl -fsS --max-time 8 \
|
||||
-H "Authorization: Bearer ${token}" \
|
||||
"${url%/}/api/plugin/context${q}" 2>/dev/null) || body=""
|
||||
[ -n "$body" ] && dyn=$(printf '%s' "$body" | jq -r '.context // empty' 2>/dev/null)
|
||||
[ -z "$dyn" ] && status="> ⚠️ Scribe: live rules/project context could not be loaded this session (instance unreachable or request failed). The standing guidance above still applies — pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\` as needed."
|
||||
elif [ -n "$url" ] && [ -z "$token" ]; then
|
||||
status="> ⚠️ Scribe: live context disabled this session — the API key is not configured (Scribe base URL is). Set it with \`/plugin\` → Scribe → configure, or export SCRIBE_TOKEN. Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
|
||||
elif [ -z "$url" ] && [ -z "$token" ]; then
|
||||
# NEITHER value arrived. Previously this case stayed silent as "an unconfigured
|
||||
# install", which made issue #2198 invisible for weeks: a *casing* bug here
|
||||
# (reading CLAUDE_PLUGIN_OPTION_api_token when Claude Code exports the key
|
||||
# UPPERCASED) looks identical to never having configured the plugin, and
|
||||
# silently disabled auto-inject and the write-path trigger too. It is not a
|
||||
# benign state — the plugin prompts for both values at enable time, so if
|
||||
# neither reached the hook, something is wrong. Say so.
|
||||
status="> ⚠️ Scribe: live context disabled this session — neither the Scribe base URL nor the API key reached this hook. Configure the plugin (\`/plugin\` → Scribe), or export SCRIBE_URL + SCRIBE_TOKEN. Note this also disables prompt auto-inject and the write-path prior-art trigger. Tools still work; pull rules with \`list_always_on_rules()\` and project context with \`enter_project()\`."
|
||||
fi
|
||||
|
||||
[ -n "$dyn" ] && append "$dyn"
|
||||
[ -n "$status" ] && append "$status"
|
||||
|
||||
# Compaction re-grounding: lead with a reload banner when this fire is a compact.
|
||||
if [ "$source" = "compact" ]; then
|
||||
prepend "> ⟳ This session was just COMPACTED — earlier turns are now a summary, so in-flight detail may be lost. Before continuing, reload your bearings from Scribe: re-pull the operator's binding rules with \`list_always_on_rules()\` (a compaction can summarize them out of context, leaving only generic harness defaults in their place), re-run \`enter_project()\` for the active project, check its open tasks and recent notes, and reconcile what you're mid-way through against what Scribe records. Don't trust half-remembered state — Scribe is the record."
|
||||
fi
|
||||
|
||||
# Nothing at all to inject → stay silent.
|
||||
[ -n "$out" ] || exit 0
|
||||
|
||||
jq -n --arg c "$out" \
|
||||
'{hookSpecificOutput: {hookEventName: "SessionStart", additionalContext: $c}}'
|
||||
exit 0
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user