Compare commits
243
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5415bff85c | ||
|
|
aba16583ab | ||
|
|
f0c915a6bc | ||
|
|
6a0f8ad328 | ||
|
|
d4c7b0e48d | ||
|
|
bfe5a461b4 | ||
|
|
3849c6fff3 | ||
|
|
1a8e5787e8 | ||
|
|
d01201539b | ||
|
|
1209e1c2d9 | ||
|
|
57d68c9355 | ||
|
|
1126bbe84f | ||
|
|
1ab614bfbe | ||
|
|
9abc4443fb | ||
|
|
3a4031d7f8 | ||
|
|
141246ac2c | ||
|
|
520381e22b | ||
|
|
58c074a324 | ||
|
|
2a6c55dacb | ||
|
|
7d48eb0b1b | ||
|
|
92e38ff17b | ||
|
|
64c641ce80 | ||
|
|
c211e12b61 | ||
|
|
b0eda32575 | ||
|
|
848ce1592e | ||
|
|
77bb3729a3 | ||
|
|
ac6f248bf9 | ||
|
|
bbee0d0db1 | ||
|
|
ac1c2625af | ||
|
|
dad56a51bd | ||
|
|
79f0283608 | ||
|
|
efc650390e | ||
|
|
d74a244b3a | ||
|
|
386b27e422 | ||
|
|
ba0030e51d | ||
|
|
a9e1cddba7 | ||
|
|
475f0857c9 | ||
|
|
9d5b89d785 | ||
|
|
3e21978a9c | ||
|
|
e2a084f1fb | ||
|
|
fd101d24ae | ||
|
|
9f1a52a035 | ||
|
|
5265d11a6a | ||
|
|
d50ebbad66 | ||
|
|
942edd1eb5 | ||
|
|
9d92df2825 | ||
|
|
9b1597a3c9 | ||
|
|
19fdc9aa89 | ||
|
|
c1cb8fb183 | ||
|
|
1faf8f3ece | ||
|
|
5c14c2621c | ||
|
|
7a5e2b18d9 | ||
|
|
d6c9f08a59 | ||
|
|
25411cf223 | ||
|
|
3162332a13 | ||
|
|
9f57cfad71 | ||
|
|
765635bbf2 | ||
|
|
cbccb6bd5d | ||
|
|
89b07f7857 | ||
|
|
eb760eb440 | ||
|
|
2fce57847b | ||
|
|
13e428c596 | ||
|
|
7d26a3fc6a | ||
|
|
1e7f66e72d | ||
|
|
8407368c0c | ||
|
|
2d58e74ec7 | ||
|
|
e05a660471 | ||
|
|
4107b17727 | ||
|
|
fb757fb4ba | ||
|
|
77acee9239 | ||
|
|
5503580875 | ||
|
|
5cb7cfe706 | ||
|
|
9c68faa0bd | ||
|
|
66c4fd1b88 | ||
|
|
266d8b8117 | ||
|
|
75bdb9c148 | ||
|
|
3455f9cb9a | ||
|
|
56b1952de5 | ||
|
|
e80f2f233b | ||
|
|
3ff8803593 | ||
|
|
854aae252a | ||
|
|
272b7dbddf | ||
|
|
063494094a | ||
|
|
23cb396e65 | ||
|
|
041d8defbc | ||
|
|
0e70a3896b | ||
|
|
6b5043a69c | ||
|
|
875f6a7b5e | ||
|
|
4ba544e2af | ||
|
|
2d1e26f38f | ||
|
|
9b3874b657 | ||
|
|
45ba4aab25 | ||
|
|
f11a547cd2 | ||
|
|
baa2492053 | ||
|
|
52bf40de4f | ||
|
|
d7039dc17c | ||
|
|
914b701a50 | ||
|
|
3f1523b19f | ||
|
|
6c4c1bccfc | ||
|
|
f20c019f2a | ||
|
|
984407f931 | ||
|
|
8d9e96cc6d | ||
|
|
5dcb738ce8 | ||
|
|
45c6b1c88a | ||
|
|
bbba0b3ae3 | ||
|
|
7defc6897c | ||
|
|
3f26aa9485 | ||
|
|
fd7097c6b7 | ||
|
|
24d071619b | ||
|
|
c18139622c | ||
|
|
ac1ce0a7f0 | ||
|
|
ffd08507f1 | ||
|
|
63c213b617 | ||
|
|
07bf58de46 | ||
|
|
11c243c0fa | ||
|
|
a6d6550483 | ||
|
|
46271ccaa7 | ||
|
|
6ac821178f | ||
|
|
4a9744172f | ||
|
|
d8dd017994 | ||
|
|
8087ba4db0 | ||
|
|
7b0984579d | ||
|
|
dcd4efcea0 | ||
|
|
4d2be27935 | ||
|
|
5b824c1626 | ||
|
|
841506b10c | ||
|
|
c34454b840 | ||
|
|
bd60d679d9 | ||
|
|
174ec8af46 | ||
|
|
4852b0d3df | ||
|
|
22f907c44d | ||
|
|
fefae606ed | ||
|
|
5795fa908a | ||
|
|
be3a0ffaf9 | ||
|
|
da6bb815bb | ||
|
|
5f8b824523 | ||
|
|
3fc693443e | ||
|
|
3d6931b838 | ||
|
|
c7cf07824a | ||
|
|
67fdf7c55b | ||
|
|
37616682f0 | ||
|
|
97b93bcaea | ||
|
|
f491b6d7b9 | ||
|
|
1a959b1db0 | ||
|
|
6d01788326 | ||
|
|
17d59fa3e0 | ||
|
|
6a6a388ecd | ||
|
|
8288c6e4a7 | ||
|
|
2cc9e1380e | ||
|
|
84541f392b | ||
|
|
da2383b079 | ||
|
|
f8522fb28f | ||
|
|
5c51e29f26 | ||
|
|
4c9a637507 | ||
|
|
731ca284c3 | ||
|
|
1e139d0d18 | ||
|
|
6eedb0f6b9 | ||
|
|
378a4b8f99 | ||
|
|
716f227bc7 | ||
|
|
67a529a38e | ||
|
|
473280e690 | ||
|
|
d1d335e293 | ||
|
|
1fde646c60 | ||
|
|
7872e7d9ec | ||
|
|
23a385e2db | ||
|
|
15eae532bd | ||
|
|
8eef9e7845 | ||
|
|
f00e9747ad | ||
|
|
15d2e0c682 | ||
|
|
0f80b790c7 | ||
|
|
46d88f9e7e | ||
|
|
b0a7d9e89b | ||
|
|
4dc57f8ab2 | ||
|
|
3da40abcb8 | ||
|
|
78489308b8 | ||
|
|
0937b1761e | ||
|
|
143b968c5d | ||
|
|
839d6902ad | ||
|
|
03b3998585 | ||
|
|
4ca3ab02c4 | ||
|
|
d3ee24f239 | ||
|
|
3c0192d749 | ||
|
|
61e6e38419 | ||
|
|
293a14361a | ||
|
|
1adf57739d | ||
|
|
6ca215d2b6 | ||
|
|
390846a3d5 | ||
|
|
a36837c96a | ||
|
|
57781770c3 | ||
|
|
0550bf4687 | ||
|
|
1fb883b72f | ||
|
|
05b64ccacb | ||
|
|
327b8a99a4 | ||
|
|
b0a0bf8abd | ||
|
|
dd878bc498 | ||
|
|
1feef179d2 | ||
|
|
51e5c22818 | ||
|
|
f039a46dae | ||
|
|
21cb9ee537 | ||
|
|
3284ac67dd | ||
|
|
fe63f3985b | ||
|
|
6db791965f | ||
|
|
84c5c0dc81 | ||
|
|
35f3f09d12 | ||
|
|
2b85443dd1 | ||
|
|
c569cdd0eb | ||
|
|
6153231f9c | ||
|
|
1b81310847 | ||
|
|
a47e1b9c4e | ||
|
|
8bab0c762b | ||
|
|
ef7ebddadf | ||
|
|
d5d0b012b1 | ||
|
|
aa850ac1e1 | ||
|
|
ca94c332ee | ||
|
|
e0328f2b1c | ||
|
|
083944f0fd | ||
|
|
dd1b5e5ddb | ||
|
|
0d396de215 | ||
|
|
9fa474b3c4 | ||
|
|
8977bed28d | ||
|
|
fc9c8119a2 | ||
|
|
cca40affe4 | ||
|
|
a8f152b7d4 | ||
|
|
4b5d9005fd | ||
|
|
3ffdbbc521 | ||
|
|
e9cd3435ad | ||
|
|
ef1dbdfc86 | ||
|
|
8b069cc93f | ||
|
|
04b58ce01e | ||
|
|
b7d6fc7e5d | ||
|
|
b33e2a79c6 | ||
|
|
7a81b7333e | ||
|
|
85625de394 | ||
|
|
eb400a521b | ||
|
|
d257c0fd67 | ||
|
|
0ea3bff797 | ||
|
|
1942913366 | ||
|
|
71d65442d3 | ||
|
|
d2f08d6113 | ||
|
|
ab9b5b647c | ||
|
|
12f71fabdf | ||
|
|
dd60244429 | ||
|
|
b49efdcb11 |
+117
-18
@@ -40,11 +40,23 @@ on:
|
|||||||
- "frontend/**"
|
- "frontend/**"
|
||||||
- "tests/**"
|
- "tests/**"
|
||||||
- "pyproject.toml"
|
- "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/**"
|
||||||
- "alembic.ini"
|
- "alembic.ini"
|
||||||
- "Dockerfile"
|
- "Dockerfile"
|
||||||
- "assets/**"
|
- "assets/**"
|
||||||
- "fable-mcp/**"
|
- "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"
|
- ".forgejo/workflows/ci.yml"
|
||||||
# Manual trigger from the Forgejo Actions UI. Useful when an image has
|
# 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
|
# been built but the deployment didn't pick it up, or when re-running
|
||||||
@@ -99,6 +111,48 @@ jobs:
|
|||||||
run: npx vue-tsc --noEmit
|
run: npx vue-tsc --noEmit
|
||||||
working-directory: frontend
|
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:
|
lint:
|
||||||
name: Python lint
|
name: Python lint
|
||||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||||
@@ -111,7 +165,27 @@ jobs:
|
|||||||
# ruff is pre-installed in the ci-python image — no install
|
# ruff is pre-installed in the ci-python image — no install
|
||||||
# step needed, lint runs in ~2s.
|
# step needed, lint runs in ~2s.
|
||||||
- name: Lint
|
- 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
|
||||||
|
|
||||||
|
# Dangling styles: an element whose classes have only modifier rules and
|
||||||
|
# no base — a deleted CSS rule that left its `:hover` behind. Two shipped
|
||||||
|
# this way (a link rendering as raw browser blue, a flex row whose parent
|
||||||
|
# was gone so every child stacked). Neither is visible to vue-tsc; a dead
|
||||||
|
# style typechecks perfectly. Reported, not gated — a bare wrapper is
|
||||||
|
# legitimate, so the signal is the count growing.
|
||||||
|
- name: Dangling style check
|
||||||
|
run: python3 scripts/check_dangling_styles.py
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Python tests
|
name: Python tests
|
||||||
@@ -126,20 +200,40 @@ jobs:
|
|||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/uv
|
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-
|
restore-keys: uv-
|
||||||
|
|
||||||
- name: Create virtual environment
|
# Installs exactly what uv.lock pins, and resolves nothing itself.
|
||||||
run: uv venv /opt/venv
|
#
|
||||||
|
# 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: Install package with dev deps
|
# The hook-EXECUTION tests (test_write_path_trigger's nudge pair) run the
|
||||||
|
# real bash hook, which exits silently without jq — and those tests skip
|
||||||
|
# rather than fail when it's absent, so without this step they would
|
||||||
|
# quietly never be verified anywhere (ci-python ships without jq; same
|
||||||
|
# install the Plugin hooks job does).
|
||||||
|
- name: Install jq for hook execution tests
|
||||||
run: |
|
run: |
|
||||||
# http-ece doesn't declare setuptools as a build dep, and uv
|
apt-get update -qq
|
||||||
# creates bare venvs without it. Install setuptools first so
|
apt-get install -y -qq --no-install-recommends jq
|
||||||
# --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]"
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
# Integration tests (real Postgres) run in the `integration` job below.
|
# Integration tests (real Postgres) run in the `integration` job below.
|
||||||
@@ -179,13 +273,12 @@ jobs:
|
|||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- name: Create virtual environment
|
# Same locked install as the unit lane — the two must agree on versions,
|
||||||
run: uv venv /opt/venv
|
# or "unit green, integration red" stops being a signal about the code.
|
||||||
- name: Install package with dev deps
|
- name: Install locked dependencies
|
||||||
run: |
|
env:
|
||||||
uv pip install --python /opt/venv/bin/python setuptools wheel
|
UV_PROJECT_ENVIRONMENT: /opt/venv
|
||||||
uv pip install --python /opt/venv/bin/python --no-build-isolation http-ece
|
run: uv sync --locked --extra dev
|
||||||
uv pip install --python /opt/venv/bin/python -e ".[dev]"
|
|
||||||
- name: Integration suite (resolve service IP, migrate, test)
|
- name: Integration suite (resolve service IP, migrate, test)
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
@@ -216,6 +309,12 @@ jobs:
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build & push image
|
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]
|
needs: [typecheck, lint, test]
|
||||||
# Build on dev, main, and v* tag pushes. dev → :dev, main → :latest,
|
# Build on dev, main, and v* tag pushes. dev → :dev, main → :latest,
|
||||||
# tag → :latest + :<version>; every build also gets an immutable :<sha>.
|
# 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
|
FROM python:3.14-slim AS runtime
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pyproject.toml .
|
# Installed from uv.lock, exactly like CI (issue #2194). This used to be
|
||||||
COPY src/ src/
|
# `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 \
|
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 --from=build-frontend /build/dist/ src/scribe/static/
|
||||||
COPY alembic.ini .
|
COPY alembic.ini .
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
# Fabled Scribe
|
# 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
|
## 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
|
## 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:
|
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
|
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.
|
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).
|
||||||
|
|
||||||
> **GPU:** Ollama runs CPU-only by default. See the comments in `docker-compose.quickstart.yml` to enable NVIDIA GPU passthrough.
|
|
||||||
|
|
||||||
> **Development:** To build from source, see [Development](docs/development.md).
|
> **Development:** To build from source, see [Development](docs/development.md).
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""retire the two settings that designated a design source for the app itself
|
||||||
|
|
||||||
|
Revision ID: 0075
|
||||||
|
Revises: 0074
|
||||||
|
Create Date: 2026-08-03
|
||||||
|
|
||||||
|
Two keys, retired for the same reason a week apart, so they go in one change
|
||||||
|
rather than one migration each:
|
||||||
|
|
||||||
|
design_rulebook_id which rulebook described how this app should look
|
||||||
|
ui_design_system_id which design system this app's own UI was built from
|
||||||
|
|
||||||
|
Both named a design source for THE RUNNING INSTALL. The design surface is for
|
||||||
|
the projects an install tracks, and a project already carries its own pointer
|
||||||
|
(`projects.design_system_id`) — so an install-wide designation had nothing left
|
||||||
|
to mean. `ui_design_system_id` was introduced by this same migration's first
|
||||||
|
draft and never reached a deployed database; it is listed here rather than
|
||||||
|
undone by an 0076 that would reverse a change nobody ran.
|
||||||
|
|
||||||
|
Deleting settings rows by key is safe in a way dropping a column is not — the
|
||||||
|
table is free-form key/value, so an install that never designated one simply has
|
||||||
|
no row to delete.
|
||||||
|
|
||||||
|
Downgrade cannot restore what it never recorded, so it is a no-op rather than a
|
||||||
|
lie: the pointer lives on the project now, and always did for anyone who set it
|
||||||
|
there.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0075"
|
||||||
|
down_revision = "0074"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"DELETE FROM settings "
|
||||||
|
"WHERE key IN ('design_rulebook_id', 'ui_design_system_id')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""note_supersessions; drop the never-written notes.consolidated_at
|
||||||
|
|
||||||
|
Revision ID: 0076
|
||||||
|
Revises: 0075
|
||||||
|
Create Date: 2026-08-07
|
||||||
|
|
||||||
|
Step 1 of milestone #278. Structure only — nothing reads or writes the new
|
||||||
|
table yet, and nothing behaves differently after this runs.
|
||||||
|
|
||||||
|
## What the table is for
|
||||||
|
|
||||||
|
Old records outrank newer ones on the same subject, because a similarity score
|
||||||
|
cannot tell time. A note that accurately described how something worked in June
|
||||||
|
is still accurate ABOUT June; it is just no longer the answer. Nothing recorded
|
||||||
|
that, so nothing could act on it.
|
||||||
|
|
||||||
|
The claim points FORWARD — the newer record names what it overtakes — because
|
||||||
|
the older one cannot know it has been overtaken. Many-to-many and partial: a
|
||||||
|
note may supersede parts of several others and be overtaken piecemeal by
|
||||||
|
several later ones, which is why this is a table rather than a column. Both
|
||||||
|
directions are queried: `superseded_id` answers "has this been overtaken?" at
|
||||||
|
ranking time, `superseder_id` answers "what does this replace?" in a record
|
||||||
|
view. An array column could serve one and not the other.
|
||||||
|
|
||||||
|
CASCADE on both sides is safe because trashing is not a delete: `trash_svc`
|
||||||
|
stamps `deleted_at`, so a trashed note keeps its claims and `restore` brings
|
||||||
|
them back. The cascade fires only on `purge_trash`, where the row genuinely
|
||||||
|
goes — and a claim about a row that no longer exists is not actionable.
|
||||||
|
|
||||||
|
## What is being dropped, and why now
|
||||||
|
|
||||||
|
`notes.consolidated_at` was written by NOTHING — no service, no route, no tool
|
||||||
|
— while being serialised into every note and task payload as `null`. It cost a
|
||||||
|
column, a line in every response, and worse: it IMPLIED a capability. A reader
|
||||||
|
reasonably concludes notes can be consolidated and this records when.
|
||||||
|
|
||||||
|
That reading was reasonable precisely because merge/unmerge exists for snippets
|
||||||
|
and not for notes, so the column looked like the notes-side half of that
|
||||||
|
feature, modelled and abandoned.
|
||||||
|
|
||||||
|
It is dropped rather than repurposed for supersession, and the distinction is
|
||||||
|
the point (#2483): consolidation folds several records into one survivor and
|
||||||
|
destroys the originals. Merging two snippets is lossless — one helper, several
|
||||||
|
call sites. Folding two dev-logs means writing a summary and losing what each
|
||||||
|
actually said. Supersession is the opposite act: both records survive, and the
|
||||||
|
older one is merely ranked behind. Smuggling one in under a column named for
|
||||||
|
the other would have buried that difference in schema.
|
||||||
|
|
||||||
|
## Downgrade
|
||||||
|
|
||||||
|
Re-adds `consolidated_at` nullable, which is how it lived — so downgrade
|
||||||
|
restores the shape, not the (nonexistent) data. Drops the table; any recorded
|
||||||
|
supersession claims are lost, which costs ranking its input and nothing else,
|
||||||
|
since no note's own content depends on them.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0076"
|
||||||
|
down_revision = "0075"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"note_supersessions",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"superseder_id",
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"superseded_id",
|
||||||
|
sa.Integer,
|
||||||
|
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"superseder_id", "superseded_id", name="uq_note_supersessions_pair"
|
||||||
|
),
|
||||||
|
# Declaring that a note supersedes ITSELF is meaningless, and under flat
|
||||||
|
# demotion it would demote a record on its own authority. Refused in the
|
||||||
|
# service too, with a message — this is the backstop that holds when
|
||||||
|
# something writes rows directly.
|
||||||
|
sa.CheckConstraint(
|
||||||
|
"superseder_id <> superseded_id", name="ck_note_supersessions_not_self"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_note_supersessions_superseder", "note_supersessions", ["superseder_id"]
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_note_supersessions_superseded", "note_supersessions", ["superseded_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
op.drop_column("notes", "consolidated_at")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"notes",
|
||||||
|
sa.Column("consolidated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.drop_index("ix_note_supersessions_superseded", table_name="note_supersessions")
|
||||||
|
op.drop_index("ix_note_supersessions_superseder", table_name="note_supersessions")
|
||||||
|
op.drop_table("note_supersessions")
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Chunked embeddings: one note_embeddings row per chunk (#280)
|
||||||
|
|
||||||
|
Revision ID: 0077
|
||||||
|
Revises: 0076
|
||||||
|
Create Date: 2026-08-09
|
||||||
|
|
||||||
|
The embedding model reads at most 512 tokens and fastembed truncates the rest
|
||||||
|
silently, so the old one-row-per-note shape permanently lost everything past
|
||||||
|
~400 words of a record. A note now stores one row per chunk of
|
||||||
|
`embeddings.chunk_document`: PK (note_id, chunk_index), plus the chunk's text
|
||||||
|
(inspectability + future "matched section" surfacing) and the chunker version
|
||||||
|
that produced it (so later shape changes re-embed by version comparison
|
||||||
|
instead of repeating this wipe).
|
||||||
|
|
||||||
|
Embeddings are DERIVED data (0067 precedent): rows are cleared here and the
|
||||||
|
startup backfill regenerates the whole corpus at the new shape on next boot.
|
||||||
|
The HNSW index is untouched — it indexes chunk rows exactly as it indexed
|
||||||
|
note rows.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0077"
|
||||||
|
down_revision = "0076"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Derived data — the version-aware startup backfill re-embeds everything
|
||||||
|
# at the chunked shape. Old whole-document rows would be indistinguishable
|
||||||
|
# from properly-chunked single-chunk notes, so they cannot be carried over.
|
||||||
|
op.execute("DELETE FROM note_embeddings")
|
||||||
|
|
||||||
|
# Empty table, so NOT NULL columns need no defaults and the PK swap is
|
||||||
|
# instant.
|
||||||
|
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunk_index integer NOT NULL")
|
||||||
|
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunk_text text NOT NULL")
|
||||||
|
op.execute("ALTER TABLE note_embeddings ADD COLUMN chunker_version integer NOT NULL")
|
||||||
|
op.execute("ALTER TABLE note_embeddings DROP CONSTRAINT note_embeddings_pkey")
|
||||||
|
op.execute(
|
||||||
|
"ALTER TABLE note_embeddings ADD PRIMARY KEY (note_id, chunk_index)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Same reasoning in reverse: chunk rows make no sense to a whole-document
|
||||||
|
# reader, so clear and let the old backfill regenerate.
|
||||||
|
op.execute("DELETE FROM note_embeddings")
|
||||||
|
op.execute("ALTER TABLE note_embeddings DROP CONSTRAINT note_embeddings_pkey")
|
||||||
|
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunk_index")
|
||||||
|
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunk_text")
|
||||||
|
op.execute("ALTER TABLE note_embeddings DROP COLUMN chunker_version")
|
||||||
|
op.execute("ALTER TABLE note_embeddings ADD PRIMARY KEY (note_id)")
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""Forge connections move to the user level (#2778)
|
||||||
|
|
||||||
|
Revision ID: 0078
|
||||||
|
Revises: 0077
|
||||||
|
Create Date: 2026-08-19
|
||||||
|
|
||||||
|
A forge token is a user's credential, not an instance's: the single
|
||||||
|
admin-settings config meant every user's snippet-freshness and coverage reads
|
||||||
|
ran under the operator's token. Each user now owns a keyring of connections —
|
||||||
|
one per forge host — and projects resolve forge reads on their OWNER's
|
||||||
|
keyring, with an optional per-project pin (projects.forge_connection_id).
|
||||||
|
|
||||||
|
The data move carries the existing admin config into a connection row for the
|
||||||
|
first admin user (host parsed from the base URL), then deletes the old
|
||||||
|
setting keys outright — no legacy dual-read (rule #22). The env-var channel
|
||||||
|
(FORGE_KIND/FORGE_BASE_URL/FORGE_TOKEN) is untouched by this migration; it
|
||||||
|
survives as an implicit keyring entry for admin users only.
|
||||||
|
"""
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0078"
|
||||||
|
down_revision = "0077"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_SETTING_KEYS = ("forge_kind", "forge_base_url", "forge_token")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"forge_connections",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("kind", sa.Text(), nullable=False),
|
||||||
|
sa.Column("base_url", sa.Text(), nullable=False),
|
||||||
|
sa.Column("host", sa.Text(), nullable=False),
|
||||||
|
sa.Column("token", sa.Text(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("user_id", "host", name="uq_forge_connections_user_host"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"projects",
|
||||||
|
sa.Column(
|
||||||
|
"forge_connection_id",
|
||||||
|
sa.BigInteger(),
|
||||||
|
sa.ForeignKey(
|
||||||
|
"forge_connections.id",
|
||||||
|
ondelete="SET NULL",
|
||||||
|
name="fk_projects_forge_connection_id",
|
||||||
|
),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Data move: the admin-settings config becomes the first admin's keyring
|
||||||
|
# row. All three values must be present — a partial config never produced
|
||||||
|
# an adapter, so carrying it over would invent a connection that never
|
||||||
|
# worked.
|
||||||
|
conn = op.get_bind()
|
||||||
|
row = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT s.key, s.value FROM settings s"
|
||||||
|
" JOIN users u ON u.id = s.user_id"
|
||||||
|
" WHERE u.role = 'admin' AND s.key IN :keys"
|
||||||
|
" AND s.user_id = ("
|
||||||
|
" SELECT MIN(id) FROM users WHERE role = 'admin'"
|
||||||
|
" )"
|
||||||
|
).bindparams(sa.bindparam("keys", expanding=True)),
|
||||||
|
{"keys": list(_SETTING_KEYS)},
|
||||||
|
).fetchall()
|
||||||
|
values = {key: (value or "").strip() for key, value in row}
|
||||||
|
kind = values.get("forge_kind", "").lower()
|
||||||
|
base_url = values.get("forge_base_url", "").rstrip("/")
|
||||||
|
token = values.get("forge_token", "")
|
||||||
|
host = (urlsplit(base_url).hostname or "").lower()
|
||||||
|
if kind and base_url and token and host:
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO forge_connections"
|
||||||
|
" (user_id, kind, base_url, host, token, created_at, updated_at)"
|
||||||
|
" SELECT MIN(id), :kind, :base_url, :host, :token, NOW(), NOW()"
|
||||||
|
" FROM users WHERE role = 'admin'"
|
||||||
|
),
|
||||||
|
{"kind": kind, "base_url": base_url, "host": host, "token": token},
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"DELETE FROM settings WHERE key IN :keys"
|
||||||
|
).bindparams(sa.bindparam("keys", expanding=True)),
|
||||||
|
{"keys": list(_SETTING_KEYS)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Reverse data move: the first admin's row (if any) becomes the admin
|
||||||
|
# settings again. Other users' rows have no pre-0078 representation and
|
||||||
|
# are dropped with the table.
|
||||||
|
conn = op.get_bind()
|
||||||
|
row = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT user_id, kind, base_url, token FROM forge_connections"
|
||||||
|
" WHERE user_id = (SELECT MIN(id) FROM users WHERE role = 'admin')"
|
||||||
|
" ORDER BY id LIMIT 1"
|
||||||
|
)
|
||||||
|
).fetchone()
|
||||||
|
if row is not None:
|
||||||
|
for key, value in (
|
||||||
|
("forge_kind", row.kind),
|
||||||
|
("forge_base_url", row.base_url),
|
||||||
|
("forge_token", row.token),
|
||||||
|
):
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"INSERT INTO settings (user_id, key, value)"
|
||||||
|
" VALUES (:uid, :key, :value)"
|
||||||
|
" ON CONFLICT (user_id, key) DO UPDATE SET value = :value"
|
||||||
|
),
|
||||||
|
{"uid": row.user_id, "key": key, "value": value},
|
||||||
|
)
|
||||||
|
op.drop_column("projects", "forge_connection_id")
|
||||||
|
op.drop_table("forge_connections")
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""The shape ledger: code_shapes (#2787, milestone 294)
|
||||||
|
|
||||||
|
Revision ID: 0079
|
||||||
|
Revises: 0078
|
||||||
|
Create Date: 2026-08-19
|
||||||
|
|
||||||
|
The accounting half of the pattern system (governing note 2786): the snippet
|
||||||
|
library records canon (small); this table accounts for EVERY shape the
|
||||||
|
coverage extractor finds in a bound repo (total). Rows arrive `unclassified`
|
||||||
|
from the coverage sync (step 2) and gain judgments — canonical / instance /
|
||||||
|
variant / exempt — from audits, hooks, and the mechanical proposer.
|
||||||
|
Unclassified IS the todo list.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0079"
|
||||||
|
down_revision = "0078"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"code_shapes",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"project_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("repo_key", sa.Text(), nullable=False),
|
||||||
|
sa.Column("path", sa.Text(), nullable=False),
|
||||||
|
sa.Column("symbol", sa.Text(), nullable=False),
|
||||||
|
sa.Column("kind", sa.Text(), nullable=False),
|
||||||
|
sa.Column("status", sa.Text(), nullable=False, server_default="unclassified"),
|
||||||
|
sa.Column(
|
||||||
|
"snippet_id",
|
||||||
|
sa.BigInteger(),
|
||||||
|
sa.ForeignKey("notes.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("classified_by", sa.Text(), nullable=True),
|
||||||
|
sa.Column("classified_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("first_seen_commit", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("last_seen_commit", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("vanished_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"project_id", "repo_key", "path", "symbol", "kind",
|
||||||
|
name="uq_code_shapes_identity",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_code_shapes_project_status", "code_shapes", ["project_id", "status"]
|
||||||
|
)
|
||||||
|
op.create_index("ix_code_shapes_snippet", "code_shapes", ["snippet_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_code_shapes_snippet", table_name="code_shapes")
|
||||||
|
op.drop_index("ix_code_shapes_project_status", table_name="code_shapes")
|
||||||
|
op.drop_table("code_shapes")
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Shape fingerprints + the mechanical proposer's columns (#2792, milestone 294)
|
||||||
|
|
||||||
|
Revision ID: 0080
|
||||||
|
Revises: 0079
|
||||||
|
Create Date: 2026-08-21
|
||||||
|
|
||||||
|
Two additions to the ledger. `signature` / `body_sha` fingerprint each shape
|
||||||
|
(definition line + a whitespace/comment-insensitive hash of its block) so the
|
||||||
|
proposer can match on content and a later drift recheck can notice change,
|
||||||
|
without the ledger ever storing code. The proposal columns carry the
|
||||||
|
proposer's standing suggestion for an unclassified row — instance-of-#N with
|
||||||
|
a basis and score, or a derive-first group key — and `proposed_sha`
|
||||||
|
remembers the content it was judged at so a refresh re-examines only what
|
||||||
|
changed. Mechanical and recomputable: a restore that lacks them loses
|
||||||
|
nothing the next refresh does not rebuild.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0080"
|
||||||
|
down_revision = "0079"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("code_shapes", sa.Column("signature", sa.Text(), nullable=False, server_default=""))
|
||||||
|
op.add_column("code_shapes", sa.Column("body_sha", sa.Text(), nullable=False, server_default=""))
|
||||||
|
op.add_column(
|
||||||
|
"code_shapes",
|
||||||
|
sa.Column(
|
||||||
|
"proposed_snippet_id",
|
||||||
|
sa.BigInteger(),
|
||||||
|
sa.ForeignKey("notes.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column("code_shapes", sa.Column("proposal_basis", sa.Text(), nullable=True))
|
||||||
|
op.add_column("code_shapes", sa.Column("proposal_score", sa.Float(), nullable=True))
|
||||||
|
op.add_column("code_shapes", sa.Column("proposal_group", sa.Text(), nullable=True))
|
||||||
|
op.add_column("code_shapes", sa.Column("proposed_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column("code_shapes", sa.Column("proposed_sha", sa.Text(), nullable=False, server_default=""))
|
||||||
|
op.create_index(
|
||||||
|
"ix_code_shapes_proposed", "code_shapes", ["project_id", "proposed_snippet_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_code_shapes_proposed", table_name="code_shapes")
|
||||||
|
for col in (
|
||||||
|
"proposed_sha", "proposed_at", "proposal_group", "proposal_score",
|
||||||
|
"proposal_basis", "proposed_snippet_id", "body_sha", "signature",
|
||||||
|
):
|
||||||
|
op.drop_column("code_shapes", col)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Shape history, recheck, and the divergence flag (#2793, milestone 294)
|
||||||
|
|
||||||
|
Revision ID: 0081
|
||||||
|
Revises: 0080
|
||||||
|
Create Date: 2026-08-21
|
||||||
|
|
||||||
|
The payoff surface of the ledger. `classified_sha` remembers the fingerprint
|
||||||
|
a judgment was made at so a later body change under an instance/variant can
|
||||||
|
flag `recheck_at`; `diverges_from` is the button-B flag (a shape new since
|
||||||
|
the previous refresh, where one canon dominates its directory+kind, and not
|
||||||
|
proposed as that canon). `code_shape_events` is the what-was-used-when
|
||||||
|
record: every classification, vanish, reappearance, and drift as it
|
||||||
|
happened — history the row alone cannot keep once it moves on.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0081"
|
||||||
|
down_revision = "0080"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("code_shapes", sa.Column("classified_sha", sa.Text(), nullable=False, server_default=""))
|
||||||
|
op.add_column("code_shapes", sa.Column("recheck_at", sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"code_shapes",
|
||||||
|
sa.Column(
|
||||||
|
"diverges_from",
|
||||||
|
sa.BigInteger(),
|
||||||
|
sa.ForeignKey("notes.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("ix_code_shapes_diverges", "code_shapes", ["project_id", "diverges_from"])
|
||||||
|
op.create_table(
|
||||||
|
"code_shape_events",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"shape_id",
|
||||||
|
sa.Integer(),
|
||||||
|
sa.ForeignKey("code_shapes.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("project_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("path", sa.Text(), nullable=False),
|
||||||
|
sa.Column("symbol", sa.Text(), nullable=False),
|
||||||
|
sa.Column("kind", sa.Text(), nullable=False),
|
||||||
|
sa.Column("event", sa.Text(), nullable=False),
|
||||||
|
sa.Column("status", sa.Text(), nullable=True),
|
||||||
|
sa.Column("snippet_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("classified_by", sa.Text(), nullable=True),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("commit", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_code_shape_events_shape", "code_shape_events", ["shape_id", "at"])
|
||||||
|
op.create_index(
|
||||||
|
"ix_code_shape_events_project_path", "code_shape_events", ["project_id", "path"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_code_shape_events_project_path", table_name="code_shape_events")
|
||||||
|
op.drop_index("ix_code_shape_events_shape", table_name="code_shape_events")
|
||||||
|
op.drop_table("code_shape_events")
|
||||||
|
op.drop_index("ix_code_shapes_diverges", table_name="code_shapes")
|
||||||
|
for col in ("diverges_from", "recheck_at", "classified_sha"):
|
||||||
|
op.drop_column("code_shapes", col)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Per-binding ref — the branch a project's ledger follows (#2873, milestone 294)
|
||||||
|
|
||||||
|
Revision ID: 0082
|
||||||
|
Revises: 0081
|
||||||
|
Create Date: 2026-08-21
|
||||||
|
|
||||||
|
A repo binding used to imply the repo's default branch; the shape ledger
|
||||||
|
therefore only saw work after a merge to main, while the operator's work
|
||||||
|
lands on dev (rule 1). `ref` names the branch the coverage refresh reads —
|
||||||
|
NULL keeps today's behaviour (the forge's default branch).
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0082"
|
||||||
|
down_revision = "0081"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("repo_bindings", sa.Column("ref", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("repo_bindings", "ref")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Exempt/variant reason codes — a small fixed catalogue beside the prose (#2874, milestone 294)
|
||||||
|
|
||||||
|
Revision ID: 0083
|
||||||
|
Revises: 0082
|
||||||
|
Create Date: 2026-08-21
|
||||||
|
|
||||||
|
The 2026-08 audit wrote the same free-text reason thousands of times
|
||||||
|
("scoped rule — styles one element of this view"); a judgment's WHY stays
|
||||||
|
prose, but an optional code from a fixed catalogue makes the ledger
|
||||||
|
filterable and aggregable ("how many pure helpers, how many test helpers").
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0083"
|
||||||
|
down_revision = "0082"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("code_shapes", sa.Column("reason_code", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("code_shapes", "reason_code")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""code_shape_uses — consumption edges, separate from conformance (#2870, milestone 294)
|
||||||
|
|
||||||
|
Revision ID: 0084
|
||||||
|
Revises: 0083
|
||||||
|
Create Date: 2026-08-21
|
||||||
|
|
||||||
|
A ledger row carries ONE snippet_id: what shape this is (instance/variant of
|
||||||
|
a canon). But a shape can also CALL several canonical helpers — e.g. a
|
||||||
|
service function both conforming to the service-function convention and
|
||||||
|
consuming hash_token. The 2026-08 audit had to pick one; hook evidence
|
||||||
|
("pulled #N then wrote code referencing it") was stamped as instance when it
|
||||||
|
is a uses fact. This table holds the many-valued relation: shape → snippet,
|
||||||
|
with the basis and the evidence. Cascades with the shape and the snippet.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0084"
|
||||||
|
down_revision = "0083"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"code_shape_uses",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("shape_id", sa.Integer(), sa.ForeignKey("code_shapes.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("snippet_id", sa.Integer(), sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("basis", sa.Text(), nullable=False),
|
||||||
|
sa.Column("evidence", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.UniqueConstraint("shape_id", "snippet_id", name="uq_code_shape_uses_shape_snippet"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_code_shape_uses_snippet", "code_shape_uses", ["snippet_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_code_shape_uses_snippet", table_name="code_shape_uses")
|
||||||
|
op.drop_table("code_shape_uses")
|
||||||
+15
-11
@@ -9,8 +9,9 @@
|
|||||||
git.fabledsword.com/bvandeusen/ci-python:3.14
|
git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||||
```
|
```
|
||||||
|
|
||||||
Used by all four jobs in `.forgejo/workflows/ci.yml`: typecheck (Vue/TS),
|
Used by all six jobs in `.forgejo/workflows/ci.yml`: typecheck (Vue/TS),
|
||||||
lint (ruff), test (pytest), build (docker buildx).
|
plugin (hook checks), lint (ruff), test (pytest), integration (pytest +
|
||||||
|
real Postgres), build (docker buildx).
|
||||||
|
|
||||||
## Image deps used
|
## 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
|
- node 24 (used for `npm ci` + `vue-tsc` in the typecheck job, and as the
|
||||||
frontend builder stage inside the production `Dockerfile`)
|
frontend builder stage inside the production `Dockerfile`)
|
||||||
- ruff (lint job runs `ruff check src/` with zero install overhead)
|
- 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
|
- docker CLI + buildx (build job pushes the production image to the
|
||||||
Forgejo registry)
|
Fabled-Git registry)
|
||||||
|
|
||||||
## Per-job tool installs
|
## Per-job tool installs
|
||||||
|
|
||||||
Anything CI installs at job time that isn't in the image. Promotion
|
Anything CI installs at job time that isn't in the image. Promotion
|
||||||
candidates if more than one project needs them.
|
candidates if more than one project needs them.
|
||||||
|
|
||||||
- `uv` — installed inline in the test job (`curl -LsSf
|
- `jq` + `shellcheck` — apt-installed in the **plugin** job, which lints
|
||||||
https://astral.sh/uv/install.sh | sh`). **Temporary**: belongs in the
|
the four Claude Code hook scripts and runs their fail-open smoke test.
|
||||||
ci-python image so every consumer doesn't re-install on cold start.
|
Per `docs/process.md`'s decision checkpoint, single-consumer deps stay
|
||||||
Tracked at [CI-Runner](https://git.fabledsword.com/bvandeusen/CI-runner).
|
per-job until a second consumer wants them; Scribe is the only one so
|
||||||
- `http-ece` is `--no-build-isolation`-installed before the editable
|
far. Both are small (jq ~1 MB, shellcheck ~20 MB) and would be
|
||||||
package install because http-ece doesn't declare `setuptools` as a
|
promotion candidates the moment another project lints shell.
|
||||||
build dep and uv creates bare venvs without it. Not promotion-worthy
|
**jq is load-bearing for the smoke test specifically**: every hook
|
||||||
(one project, one wheel).
|
starts with `command -v jq || exit 0`, so without it the test passes
|
||||||
|
while exercising nothing.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| Tasks | `create_task`, `update_task`, `add_task_log`, `start_planning` | Actionable work + plans |
|
||||||
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
|
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
|
||||||
| Search / Recall | `search`, `get_recent`, `list_tags` | Semantic + structured recall |
|
| Search / Recall | `search`, `get_recent`, `list_tags` | Semantic + structured recall |
|
||||||
| Typed entities | `create_person`, `create_place`, `create_list`, … | Structured records |
|
| Systems | `create_system`, `list_systems`, `list_system_records` | Reusable per-project subsystems/areas |
|
||||||
| Events | `create_event`, `list_events`, `update_event`, … | Calendar |
|
|
||||||
| Rulebooks | `list_always_on_rules`, `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules |
|
| 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 |
|
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
|
||||||
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
|
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
|
||||||
|
|||||||
+135
-171
@@ -1,242 +1,206 @@
|
|||||||
# API Reference
|
# 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
|
## Health
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/health` | Health check **(public)** |
|
| GET | `/api/health` | Health check **(public)** |
|
||||||
|
| GET | `/api/version` | App version **(public)** |
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/auth/status` | `{has_users, registration_open, oauth_enabled, local_auth_enabled}` **(public)** |
|
| 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/register` | Register (first user becomes admin) |
|
||||||
| POST | `/api/auth/login` | Login with username/password (403 if local auth disabled) |
|
| POST | `/api/auth/login` | Login with username/password |
|
||||||
| POST | `/api/auth/logout` | Clear session |
|
| POST | `/api/auth/logout` | Clear session |
|
||||||
| GET | `/api/auth/me` | Current user info (includes `has_password: bool`) |
|
| GET | `/api/auth/me` | Current user info |
|
||||||
| PUT | `/api/auth/password` | Change password `{current_password, new_password}` |
|
| PUT | `/api/auth/password` | Change password |
|
||||||
| PUT | `/api/auth/email` | Change email `{email, password?}` (password required only for local-auth users) |
|
| PUT | `/api/auth/email` | Change email |
|
||||||
| POST | `/api/auth/invalidate-sessions` | Bump `session_version` — evicts all other sessions, keeps current alive |
|
| POST | `/api/auth/invalidate-sessions` | Evict all other sessions |
|
||||||
| POST | `/api/auth/forgot-password` | Send password reset email `{email}` |
|
| POST | `/api/auth/forgot-password` | Send password-reset email |
|
||||||
| POST | `/api/auth/reset-password` | Reset password with token `{token, new_password}` |
|
| POST | `/api/auth/reset-password` | Reset password with token |
|
||||||
| GET | `/api/auth/oauth/login` | Initiate OIDC PKCE flow → redirect to provider |
|
| GET | `/api/auth/oauth/login` | Begin OIDC (PKCE) flow |
|
||||||
| GET | `/api/auth/oauth/callback` | OIDC callback — exchange code, find/create user, redirect to `/` |
|
| GET | `/api/auth/oauth/callback` | OIDC callback |
|
||||||
| GET | `/api/auth/invitation/:token` | Validate invitation token **(public)** |
|
| GET | `/api/auth/invitation/:token` | Validate an invite **(public)** |
|
||||||
| POST | `/api/auth/register-with-invite` | Register with token `{token, username, password}` **(public)** |
|
| POST | `/api/auth/register-with-invite` | Register with a token **(public)** |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
| Method | Path | Description |
|
| 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`) |
|
| GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`, `project_id`, `milestone_id`, `parent_id`) |
|
||||||
| POST | `/api/notes` | Create note `{title, body, tags?, status?, priority?, due_date?, project_id?, milestone_id?, parent_id?}` |
|
| POST | `/api/notes` | Create note |
|
||||||
| GET | `/api/notes/tags` | All tags (param: `q` for filter) |
|
| GET | `/api/notes/tags` | All tags (param: `q`) |
|
||||||
| POST | `/api/notes/suggest-tags` | LLM tag suggestions `{title, body, current_tags?}` → `{suggested_tags}` |
|
| POST | `/api/notes/:id/append-tag` | Add a tag `{tag}` |
|
||||||
| POST | `/api/notes/link-suggestions` | Detect note titles as plain text in body `{body, project_id, exclude_note_id}` → `[{note_id, title, count}]` |
|
| POST | `/api/notes/link-suggestions` | Detect note titles as plain text → wikilink candidates |
|
||||||
| GET | `/api/notes/by-title` | Resolve note by exact title (param: `title`) |
|
| GET | `/api/notes/by-title` | Resolve note by exact title |
|
||||||
| POST | `/api/notes/resolve-title` | Get-or-create note by title `{title}` (wikilink click) |
|
| POST | `/api/notes/resolve-title` | Get-or-create by title (wikilink click) |
|
||||||
| GET | `/api/notes/:id` | Get single note |
|
| GET / PUT / PATCH / DELETE | `/api/notes/:id` | Read / replace / patch / delete |
|
||||||
| PUT | `/api/notes/:id` | Full update |
|
| POST | `/api/notes/:id/convert-to-task` | Note → task |
|
||||||
| PATCH | `/api/notes/:id` | Partial update (same fields as PUT) |
|
| POST | `/api/notes/:id/convert-to-note` | Task → note |
|
||||||
| 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/:id/backlinks` | Notes/tasks with `[[Title]]` references to this 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` | Version history |
|
||||||
| GET | `/api/notes/:id/versions/:vid` | Get a specific version |
|
| GET | `/api/notes/:id/versions/:vid` | A specific version |
|
||||||
| GET | `/api/notes/:id/draft` | Get current AI draft |
|
| POST | `/api/notes/:id/versions/:vid/pin` | Pin a version |
|
||||||
| PUT | `/api/notes/:id/draft` | Save AI draft |
|
| GET / PUT / DELETE | `/api/notes/:id/draft` | Unsaved-edit draft (restore across page loads) |
|
||||||
| DELETE | `/api/notes/:id/draft` | Delete AI draft |
|
| GET | `/api/notes/graph` | Knowledge-graph nodes/edges |
|
||||||
| 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`) |
|
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/tasks` | List tasks. Params: `q`, `tag`, `status`, `priority`, `due_before`, `due_after`, `sort`, `order`, `limit`, `offset` |
|
| GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `overdue`, `sort`, `order`, `limit`, `offset`) |
|
||||||
| POST | `/api/tasks` | Create task (accepts `project` name string → resolved to `project_id`) |
|
| POST | `/api/tasks` | Create task (accepts a `project` name string → resolved to `project_id`) |
|
||||||
| GET | `/api/tasks/:id` | Get task (includes `parent_title`) |
|
| POST | `/api/tasks/planning` | Start a plan (milestone + steps) |
|
||||||
| PUT | `/api/tasks/:id` | Full update |
|
| GET / PUT / PATCH / DELETE | `/api/tasks/:id` | Read / update / delete |
|
||||||
| PATCH | `/api/tasks/:id/status` | Quick status update `{status}` |
|
| PATCH | `/api/tasks/:id/status` | Quick status update `{status}` |
|
||||||
| DELETE | `/api/tasks/:id` | Delete task |
|
| GET | `/api/tasks/:id/recurrence-preview` | Preview next recurrence occurrences |
|
||||||
| 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 |
|
|
||||||
|
|
||||||
## Projects & Milestones
|
**Task work logs**
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/projects` | List projects (owned + shared) |
|
| GET / POST | `/api/tasks/:id/logs` | List / append a work log `{content, duration_minutes?}` |
|
||||||
| POST | `/api/projects` | Create project |
|
| PATCH / DELETE | `/api/tasks/:id/logs/:log_id` | Update / delete a log |
|
||||||
| GET | `/api/projects/:id` | Get project with `milestone_summary` |
|
|
||||||
| PATCH | `/api/projects/:id` | Update project |
|
## Projects, Milestones, Systems, Issues
|
||||||
| DELETE | `/api/projects/:id` | Delete project |
|
|
||||||
|
| 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/notes` | Notes + tasks in this project |
|
||||||
| GET | `/api/projects/:id/milestones` | List milestones |
|
| GET / POST | `/api/projects/:id/milestones` | List / create milestones |
|
||||||
| POST | `/api/projects/:id/milestones` | Create milestone |
|
| GET / PATCH / DELETE | `/api/projects/:id/milestones/:mid` | Read / update / delete |
|
||||||
| PATCH | `/api/projects/:id/milestones/:mid` | Update milestone |
|
| GET | `/api/projects/:id/milestones/:mid/tasks` | Tasks in a milestone |
|
||||||
| DELETE | `/api/projects/:id/milestones/:mid` | Delete 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
|
## Sharing
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/projects/:id/shares` | List project shares |
|
| GET / POST | `/api/projects/:id/shares` | List / add project shares `{user_id?, group_id?, permission}` |
|
||||||
| POST | `/api/projects/:id/shares` | Create project share `{user_id?, group_id?, permission}` |
|
| PATCH / DELETE | `/api/projects/:id/shares/:sid` | Update permission / remove |
|
||||||
| PATCH | `/api/projects/:id/shares/:sid` | Update permission |
|
| GET / POST | `/api/notes/:id/shares` | List / add note shares |
|
||||||
| DELETE | `/api/projects/:id/shares/:sid` | Remove share |
|
| PATCH / DELETE | `/api/notes/:id/shares/:sid` | Update permission / remove |
|
||||||
| 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 | `/api/shared-with-me` | All resources shared with the current user |
|
| GET | `/api/shared-with-me` | All resources shared with the current user |
|
||||||
|
|
||||||
## Groups
|
## Groups
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/groups` | List all groups (admin only) |
|
| GET / POST | `/api/groups` | List / create groups |
|
||||||
| POST | `/api/groups` | Create group `{name, description?}` |
|
| GET / PATCH / DELETE | `/api/groups/:id` | Read / update / delete |
|
||||||
| PATCH | `/api/groups/:id` | Update group |
|
| GET / POST | `/api/groups/:id/members` | List / add members `{user_id, role}` |
|
||||||
| DELETE | `/api/groups/:id` | Delete group |
|
| PATCH / DELETE | `/api/groups/:id/members/:uid` | Update role / remove |
|
||||||
| 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.
|
|
||||||
|
|
||||||
## Notifications
|
## Notifications
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/notifications` | List notifications |
|
| GET | `/api/notifications` | List in-app notifications |
|
||||||
| GET | `/api/notifications/count` | Unread count |
|
| 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 |
|
| POST | `/api/notifications/read-all` | Mark all read |
|
||||||
|
|
||||||
## Push
|
## Profile and Settings
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/push/vapid-public-key` | VAPID public key for subscription |
|
| GET / PUT | `/api/profile` | Read / update the per-user profile |
|
||||||
| POST | `/api/push/subscribe` | Register push subscription |
|
| GET / PUT | `/api/settings` | Read / update key-value settings |
|
||||||
| DELETE | `/api/push/subscribe` | Unregister push subscription |
|
| GET | `/api/settings/search` | SearXNG configuration status |
|
||||||
|
|
||||||
## Images
|
## API keys
|
||||||
|
|
||||||
| Method | Path | Description |
|
| 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 |
|
| 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 |
|
| 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
|
## Admin
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/admin/backup` | Export backup (`?scope=user` for own data; full requires admin) |
|
| GET | `/api/admin/backup` | Export backup (format v4; `?scope=user` for own data) |
|
||||||
| POST | `/api/admin/restore` | Restore from JSON backup |
|
| POST | `/api/admin/restore` | Restore from a backup |
|
||||||
| GET | `/api/admin/users` | List all users |
|
| GET / DELETE | `/api/admin/users` · `/api/admin/users/:id` | List / delete users |
|
||||||
| DELETE | `/api/admin/users/:id` | Delete user (cannot delete self) |
|
| GET / PUT | `/api/admin/registration` | Get / toggle registration |
|
||||||
| GET | `/api/admin/registration` | Get registration open/closed state |
|
| GET / PUT | `/api/admin/smtp` · POST `/api/admin/smtp/test` | SMTP config + test email |
|
||||||
| PUT | `/api/admin/registration` | Toggle registration `{open: bool}` |
|
| GET | `/api/admin/logs` · `/api/admin/logs/stats` | Log entries + category counts |
|
||||||
| POST | `/api/admin/invitations` | Create invitation `{email}` → sends email |
|
| GET / PUT | `/api/admin/base-url` | Get / set the public base URL |
|
||||||
| GET | `/api/admin/invitations` | List pending invitations |
|
| GET / PUT | `/api/admin/db-maintenance` (+ `/health`, POST `/run`) | VACUUM schedule, health, manual run |
|
||||||
| DELETE | `/api/admin/invitations/:id` | Revoke invitation |
|
| POST / GET / DELETE | `/api/admin/invitations` (+ `/:id`) | Create / list / revoke invite links |
|
||||||
| 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 |
|
## Scribe MCP
|
||||||
| GET | `/api/admin/base-url` | Get base URL setting |
|
|
||||||
| PUT | `/api/admin/base-url` | Set base URL `{base_url}` |
|
Claude clients connect to the built-in MCP server at `POST /mcp` (streamable HTTP,
|
||||||
| GET | `/api/admin/smtp` | Get SMTP config (password masked) |
|
Bearer auth with an `fmcp_` key), served by `src/scribe/mcp/`. It is not a REST
|
||||||
| PUT | `/api/admin/smtp` | Save SMTP config |
|
surface — it exposes the same data as typed tools (`create_note`, `create_task`,
|
||||||
| POST | `/api/admin/smtp/test` | Send test email `{recipient}` |
|
`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
|
# 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
|
## 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.
|
- **Work logs** — Append timestamped progress entries (with optional duration) to a
|
||||||
|
task without rewriting its body; shown chronologically in the task view.
|
||||||
**Link suggestions** — The editor detects note titles appearing as plain text in the body and suggests converting them to wikilinks.
|
- **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
|
||||||
## Tasks
|
from and the System(s) it touches.
|
||||||
|
- **Recurring tasks** — An interval or calendar recurrence rule spawns the next
|
||||||
Tasks carry status (`todo` → `in_progress` → `done`), priority (`none`/`low`/`medium`/`high`), due date, milestone assignment, and a parent task (sub-tasks).
|
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
|
||||||
**Task work logs** — Append progress log entries to a task with optional duration. Time tracking is visible in the task editor sidebar.
|
inline.
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
## Projects and Milestones
|
## 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
|
## 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:
|
The whole store is reachable by Claude through a built-in **MCP endpoint at `/mcp`**
|
||||||
- **RAG** — Semantically relevant notes (≥ 0.60 cosine similarity) auto-injected as context. Notes 0.45–0.60 shown in sidebar as "Suggested."
|
(Bearer-auth with an API key). The **Scribe Claude Code plugin** (shipped in this
|
||||||
- **Attach notes** — Paperclip icon to include specific notes in context.
|
repo) wires it up:
|
||||||
- **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).
|
|
||||||
|
|
||||||
## 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.
|
See [API Keys & MCP](api-keys-and-mcp.md).
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
## Sharing and Collaboration
|
## 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.
|
Every read and mutation is scoped by owner + direct shares + group shares.
|
||||||
|
|
||||||
**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.
|
|
||||||
|
|
||||||
## Data Export and Backup
|
## Data Export and Backup
|
||||||
|
|
||||||
- **Personal export** — Settings → Data: download all notes/tasks as a Markdown ZIP (with YAML frontmatter) or JSON array.
|
- **Personal export** — download all your notes/tasks as a Markdown ZIP (YAML
|
||||||
- **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.
|
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
|
||||||
|
|
||||||
Settings are tabbed:
|
|
||||||
|
|
||||||
| Tab | Contents |
|
| Tab | Contents |
|
||||||
|-----|----------|
|
|-----|----------|
|
||||||
| General | Assistant name, default model, model management (pull/delete) |
|
| General | Instance preferences (key/value) |
|
||||||
| Account | Email change, password change, session invalidation |
|
| Account | Email change, password change, session invalidation |
|
||||||
| Notifications | Push notification subscription, journal prep push toggle |
|
| Profile | Per-user profile fields |
|
||||||
| Profile | About you, response preferences, interests, work schedule, locations + temperature unit, journal prep schedule, learned observations |
|
| Integrations | SearXNG status |
|
||||||
| Integrations | CalDAV configuration, SearXNG status |
|
| Data | Personal export; backup / restore (admin) |
|
||||||
| Data | Personal export, backup/restore (admin) |
|
| API Keys | Create/revoke `fmcp_` keys for the MCP endpoint |
|
||||||
| API Keys | Create/revoke API keys, Fable MCP download and install |
|
| Config (admin) | Base URL, SMTP, DB-maintenance schedule |
|
||||||
| Config (admin) | Base URL, SMTP, OIDC settings |
|
|
||||||
| Users (admin) | User list, invite links, registration toggle |
|
| Users (admin) | User list, invite links, registration toggle |
|
||||||
| Logs (admin) | Error, audit, and usage logs with search |
|
| Logs (admin) | Error, audit, and usage logs with search |
|
||||||
| Groups (admin) | Create/manage groups and membership |
|
| 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
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|-----|--------|
|
|-----|--------|
|
||||||
| `g` + `h` | Go to Home |
|
| `g` + `h` | Home (dashboard) |
|
||||||
| `g` + `n` | Go to Notes |
|
| `g` + `n` | Notes |
|
||||||
| `g` + `t` | Go to Tasks |
|
| `g` + `t` | Knowledge (tasks) |
|
||||||
| `g` + `p` | Go to Projects |
|
| `g` + `p` | Projects |
|
||||||
| `g` + `c` | Go to Chat |
|
| `g` + `r` | Rulebooks |
|
||||||
| `g` (bare) | Go to Graph |
|
| `g` (bare) | Graph |
|
||||||
|
| `g` + `x` | Trash |
|
||||||
| `n` | New note |
|
| `n` | New note |
|
||||||
| `t` | New task |
|
| `t` | New task |
|
||||||
| `c` | Focus chat input |
|
| `/` | Focus search |
|
||||||
| `e` | Edit current item |
|
|
||||||
| `/` | Search |
|
|
||||||
| `?` | Show shortcuts panel |
|
| `?` | Show shortcuts panel |
|
||||||
| `j` / `k` | Navigate list items |
|
| `j` / `k` | Navigate list items |
|
||||||
| `Enter` | Open selected item |
|
| `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 |
|
| `Ctrl+S` | Save in editor |
|
||||||
|
|||||||
+18
-25
@@ -81,7 +81,6 @@ function onGlobalKeydown(e: KeyboardEvent) {
|
|||||||
case "p": router.push("/projects"); break;
|
case "p": router.push("/projects"); break;
|
||||||
case "r": router.push("/rules"); break;
|
case "r": router.push("/rules"); break;
|
||||||
case "g": router.push("/graph"); break;
|
case "g": router.push("/graph"); break;
|
||||||
case "l": router.push("/calendar"); break;
|
|
||||||
case "x": router.push("/trash"); break;
|
case "x": router.push("/trash"); break;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -190,12 +189,6 @@ onUnmounted(() => {
|
|||||||
<kbd class="shortcut-key">p</kbd>
|
<kbd class="shortcut-key">p</kbd>
|
||||||
<span class="shortcut-desc">Projects</span>
|
<span class="shortcut-desc">Projects</span>
|
||||||
</div>
|
</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">
|
<div class="shortcut-row">
|
||||||
<kbd class="shortcut-key">g</kbd>
|
<kbd class="shortcut-key">g</kbd>
|
||||||
<span class="shortcut-key-sep">+</span>
|
<span class="shortcut-key-sep">+</span>
|
||||||
@@ -261,8 +254,8 @@ onUnmounted(() => {
|
|||||||
left: 0.5rem;
|
left: 0.5rem;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
background: var(--color-primary);
|
background: var(--fs-accent);
|
||||||
color: #fff;
|
color: var(--fs-text-on-action);
|
||||||
border-radius: 0 0 4px 4px;
|
border-radius: 0 0 4px 4px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -297,7 +290,7 @@ onUnmounted(() => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0.2rem 0;
|
padding: 0.2rem 0;
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
@@ -307,17 +300,17 @@ onUnmounted(() => {
|
|||||||
.shortcuts-overlay {
|
.shortcuts-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: var(--color-overlay, rgba(0, 0, 0, 0.45));
|
background: var(--fs-overlay);
|
||||||
z-index: 9000;
|
z-index: 9000;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.shortcuts-panel {
|
.shortcuts-panel {
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-md, 8px);
|
border-radius: var(--fs-radius-lg);
|
||||||
box-shadow: 0 8px 32px var(--color-shadow, rgba(0,0,0,0.2));
|
box-shadow: 0 8px 32px var(--color-shadow);
|
||||||
width: min(420px, 92vw);
|
width: min(420px, 92vw);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -326,25 +319,25 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.85rem 1rem 0.75rem;
|
padding: 0.85rem 1rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
.shortcuts-header h3 {
|
.shortcuts-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.shortcuts-close {
|
.shortcuts-close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
}
|
}
|
||||||
.shortcuts-close:hover {
|
.shortcuts-close:hover {
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.shortcuts-body {
|
.shortcuts-body {
|
||||||
padding: 0.75rem 1rem 1rem;
|
padding: 0.75rem 1rem 1rem;
|
||||||
@@ -357,7 +350,7 @@ onUnmounted(() => {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
.shortcut-row {
|
.shortcut-row {
|
||||||
@@ -372,23 +365,23 @@ onUnmounted(() => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 1.8rem;
|
min-width: 1.8rem;
|
||||||
padding: 0.15rem 0.4rem;
|
padding: 0.15rem 0.4rem;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-bottom-width: 2px;
|
border-bottom-width: 2px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-family: ui-monospace, monospace;
|
font-family: ui-monospace, monospace;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.shortcut-key-sep {
|
.shortcut-key-sep {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.shortcut-desc {
|
.shortcut-desc {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-66
@@ -38,6 +38,20 @@ async function handleResponse<T>(res: Response, path: string): Promise<T> {
|
|||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server's `{"error": "..."}` message from a failed call, or `fallback`
|
||||||
|
* when the failure carried none (network error, non-JSON body). The one place
|
||||||
|
* the error envelope is unpacked on the client — views used to restate this
|
||||||
|
* as a six-line `"body" in e` branch at every catch site.
|
||||||
|
*/
|
||||||
|
export function apiErrorMessage(e: unknown, fallback: string): string {
|
||||||
|
if (e && typeof e === "object" && "body" in e) {
|
||||||
|
const body = (e as { body?: { error?: unknown } }).body;
|
||||||
|
if (body && typeof body.error === "string" && body.error) return body.error;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiGet<T>(path: string): Promise<T> {
|
export async function apiGet<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(path);
|
const res = await fetch(path);
|
||||||
return handleResponse<T>(res, path);
|
return handleResponse<T>(res, path);
|
||||||
@@ -374,72 +388,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 ─────────────────────────────────────────────────────────────────
|
// ─── API Keys ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ApiKeyEntry {
|
export interface ApiKeyEntry {
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* `projectId` narrows to the snippets one project owns — which is how a
|
||||||
|
* project asks about its OWN code. Omit it to check every project, which is
|
||||||
|
* the right default from the system's side: a component recorded elsewhere
|
||||||
|
* still has to use the same tags. */
|
||||||
|
export const checkSnippets = (id: number, projectId?: number) =>
|
||||||
|
apiGet<SnippetCheck>(
|
||||||
|
`/api/design-systems/${id}/snippet-check`
|
||||||
|
+ (projectId ? `?project_id=${projectId}` : ""),
|
||||||
|
);
|
||||||
@@ -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,115 @@
|
|||||||
|
/* ── Auth surface (Login / Register / RegisterInvite / ForgotPassword / ResetPassword) ──
|
||||||
|
The five auth views used to carry byte-identical copies of these rules in
|
||||||
|
their scoped blocks (2026-08 shape audit). Loaded per view with
|
||||||
|
<style src="@/assets/auth-shared.css" />, like editor-shared.css; the form
|
||||||
|
rules are scoped under .auth-card so nothing leaks into the app's other
|
||||||
|
.field/.input usages. Per-view one-offs (Login's .divider/.forgot-link)
|
||||||
|
stay in the view. */
|
||||||
|
.auth-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-lg);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
.auth-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.auth-card h1 {
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.auth-hint {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.auth-hint a {
|
||||||
|
color: var(--fs-accent);
|
||||||
|
}
|
||||||
|
/* A centred status paragraph block: registration closed, invalid/expired
|
||||||
|
token, "check your inbox". One rule — the views used to name it
|
||||||
|
.closed-msg / .error-block / .success-msg with identical bodies. */
|
||||||
|
.auth-note {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.auth-note p {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
.auth-loading {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
.auth-card .field {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.auth-card .field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
.auth-card .input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
background: var(--fs-surface-page);
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.auth-card .input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--fs-accent);
|
||||||
|
}
|
||||||
|
.auth-card .input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.auth-card .input-error,
|
||||||
|
.auth-card .input-error:focus {
|
||||||
|
border-color: var(--fs-error);
|
||||||
|
}
|
||||||
|
.auth-card .field-hint {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
.auth-card .error-hint {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--fs-error);
|
||||||
|
}
|
||||||
|
.auth-card .error-msg {
|
||||||
|
color: var(--fs-error);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
.auth-footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
margin: 1rem 0 0;
|
||||||
|
}
|
||||||
|
.auth-footer a {
|
||||||
|
color: var(--fs-accent);
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
/* 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,
|
||||||
|
.btn-cta {
|
||||||
|
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;
|
||||||
|
/* So a button carrying an icon centres it against the label without each
|
||||||
|
caller re-inventing the flex row — the shape they all reached for
|
||||||
|
separately, and the reason icon buttons sat a pixel or two off. */
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--fs-space-2);
|
||||||
|
text-decoration: none;
|
||||||
|
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,
|
||||||
|
.btn-cta: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,
|
||||||
|
.btn-cta: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(--fs-action-primary);
|
||||||
|
color: var(--fs-text-on-action);
|
||||||
|
}
|
||||||
|
.btn-primary:not(:disabled):hover {
|
||||||
|
background: var(--fs-action-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--fs-action-secondary);
|
||||||
|
color: var(--fs-text-on-action);
|
||||||
|
}
|
||||||
|
.btn-secondary:not(:disabled):hover {
|
||||||
|
background: var(--fs-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(--fs-text-primary);
|
||||||
|
}
|
||||||
|
.btn-ghost:not(:disabled):hover {
|
||||||
|
border: var(--fs-border-hover);
|
||||||
|
background: var(--fs-surface-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(--fs-action-destructive);
|
||||||
|
color: var(--fs-text-on-action);
|
||||||
|
}
|
||||||
|
.btn-danger:not(:disabled):hover {
|
||||||
|
background: var(--fs-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(--fs-text-tertiary);
|
||||||
|
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(--fs-text-primary); }
|
||||||
|
.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(--fs-action-destructive);
|
||||||
|
color: var(--fs-action-destructive);
|
||||||
|
}
|
||||||
|
.btn-danger-outline:not(:disabled):hover {
|
||||||
|
background: var(--fs-action-destructive);
|
||||||
|
color: var(--fs-text-on-action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The one place the accent is allowed on a button: a deliberate brand moment,
|
||||||
|
* never an ordinary action. The system carries `--fs-gradient-cta` and
|
||||||
|
* `--fs-glow-cta` for exactly this and nothing else was using them.
|
||||||
|
*
|
||||||
|
* It exists because ProjectView's Workspace link WAS this button, defined in a
|
||||||
|
* scoped block that the migration deleted — leaving a `:hover` rule with no
|
||||||
|
* base and a link that rendered as raw browser blue. A variant living in one
|
||||||
|
* view is a variant waiting to be deleted by someone tidying another; this is
|
||||||
|
* the shared home so the next sweep can't strand it. */
|
||||||
|
.btn-cta {
|
||||||
|
background: var(--fs-gradient-cta);
|
||||||
|
color: var(--fs-text-on-action);
|
||||||
|
box-shadow: var(--fs-glow-cta);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn-cta:not(:disabled):hover {
|
||||||
|
box-shadow: var(--fs-glow-cta-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 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: flex; /* not `block` — the shared shape centres with flex */
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Modal ─────────────────────────────────────────────────────────────────
|
||||||
|
The one overlay/card/button shape for every in-app dialog (ConfirmDialog,
|
||||||
|
the create-project / merge-snippet / systems dialogs, the editors' confirm
|
||||||
|
prompts). Global on purpose: ConfirmDialog teleports to <body> and has no
|
||||||
|
styles of its own, so these must be loaded with the app, not with whichever
|
||||||
|
view happens to be open. Views add only their own overrides (a wider card,
|
||||||
|
a form layout). Destructive = action-destructive per the Hybrid rule. */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--fs-overlay);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.modal-card {
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-lg);
|
||||||
|
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(--fs-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(--fs-border-color);
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.modal-btn:hover {
|
||||||
|
background: var(--fs-surface-page);
|
||||||
|
}
|
||||||
|
.modal-btn-primary {
|
||||||
|
background: var(--fs-action-primary);
|
||||||
|
border-color: var(--fs-action-primary);
|
||||||
|
color: var(--fs-text-on-action);
|
||||||
|
}
|
||||||
|
.modal-btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--fs-action-primary-hover);
|
||||||
|
}
|
||||||
|
.modal-btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.modal-btn-danger {
|
||||||
|
background: var(--fs-action-destructive);
|
||||||
|
border-color: var(--fs-action-destructive);
|
||||||
|
color: var(--fs-text-on-action);
|
||||||
|
}
|
||||||
|
.modal-btn-danger:hover {
|
||||||
|
background: var(--fs-action-destructive-hover);
|
||||||
|
border-color: var(--fs-action-destructive-hover);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 1rem 1.5rem 0.5rem;
|
padding: 1rem 1.5rem 0.5rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
.editor-body {
|
.editor-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -34,118 +34,43 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
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
|
/* Save: Moss action-primary per the Hybrid rule. Saving is "operating
|
||||||
the software" — not a brand moment. Accent gradient is reserved for
|
the software" — not a brand moment. Accent gradient is reserved for
|
||||||
Send / empty-state CTAs. */
|
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
|
/* Delete: Oxblood action-destructive per Hybrid rule. Should be paired
|
||||||
with a Trash icon at the call site to reinforce intent. */
|
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 {
|
.title-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-bottom-color: var(--color-primary);
|
border-bottom-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.title-input::placeholder {
|
.title-input::placeholder {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
.editor-tabs {
|
.editor-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
.tab {
|
.tab {
|
||||||
padding: 0.45rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
}
|
}
|
||||||
.tab.active {
|
.tab.active {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
border-bottom-color: var(--color-primary);
|
border-bottom-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.preview-pane {
|
.preview-pane {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tag suggestions ── */
|
/* ── Tag suggestions ── */
|
||||||
@@ -155,68 +80,39 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
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 {
|
.tag-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
padding: 0.2rem 0.55rem;
|
padding: 0.2rem 0.55rem;
|
||||||
border: 1px solid var(--color-primary);
|
border: 1px solid var(--fs-accent);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.tag-pill:hover:not(:disabled) {
|
.tag-pill:hover:not(:disabled) {
|
||||||
background: var(--color-primary);
|
background: var(--fs-accent);
|
||||||
color: #fff;
|
color: var(--fs-text-on-action);
|
||||||
}
|
}
|
||||||
.tag-pill.applied {
|
.tag-pill.applied {
|
||||||
background: var(--color-success, #2ecc71);
|
background: var(--fs-success);
|
||||||
border-color: var(--color-success, #2ecc71);
|
border-color: var(--fs-success);
|
||||||
color: #fff;
|
color: var(--fs-text-on-action);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.tag-check {
|
.tag-check {
|
||||||
font-size: 0.7rem;
|
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 ── */
|
||||||
.assist-panel {
|
.assist-panel {
|
||||||
width: 320px;
|
width: 320px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-left: 1px solid var(--color-border);
|
border-left: 1px solid var(--fs-border-color);
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -227,42 +123,16 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.65rem 0.9rem;
|
padding: 0.65rem 0.9rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
.assist-panel-title {
|
.assist-panel-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
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 {
|
.assist-panel-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -279,13 +149,13 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
margin-bottom: 0.2rem;
|
margin-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
.assist-sections {
|
.assist-sections {
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -295,46 +165,46 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.assist-section-item:hover {
|
.assist-section-item:hover {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
}
|
}
|
||||||
.assist-section-item.selected {
|
.assist-section-item.selected {
|
||||||
border-left-color: var(--color-primary);
|
border-left-color: var(--fs-accent);
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.assist-empty,
|
.assist-empty,
|
||||||
.assist-hint {
|
.assist-hint {
|
||||||
padding: 0.6rem 0.7rem;
|
padding: 0.6rem 0.7rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.assist-target-preview {
|
.assist-target-preview {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.assist-target-preview em {
|
.assist-target-preview em {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.assist-instruction {
|
.assist-instruction {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.65rem;
|
padding: 0.5rem 0.65rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-height: 3.5rem;
|
min-height: 3.5rem;
|
||||||
}
|
}
|
||||||
@@ -342,48 +212,26 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
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 */
|
/* Streaming */
|
||||||
.assist-streaming-label {
|
.assist-streaming-label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.assist-preview-box {
|
.assist-preview-box {
|
||||||
padding: 0.65rem;
|
padding: 0.65rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.typing-indicator {
|
.typing-indicator {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
letter-spacing: 0.15em;
|
letter-spacing: 0.15em;
|
||||||
animation: blink 1s step-end infinite;
|
animation: blink 1s step-end infinite;
|
||||||
@@ -396,18 +244,18 @@
|
|||||||
.assist-active-hint {
|
.assist-active-hint {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Error */
|
/* Error */
|
||||||
.assist-error {
|
.assist-error {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
|
||||||
border: 1px solid var(--color-danger);
|
border: 1px solid var(--fs-error);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-danger);
|
color: var(--fs-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Review / diff */
|
/* Review / diff */
|
||||||
@@ -417,20 +265,12 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
}
|
|
||||||
.btn-toggle-view {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-primary);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
}
|
}
|
||||||
.diff-view {
|
.diff-view {
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
max-height: 340px;
|
max-height: 340px;
|
||||||
@@ -444,15 +284,15 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.diff-delete {
|
.diff-delete {
|
||||||
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
|
||||||
color: var(--color-danger);
|
color: var(--fs-error);
|
||||||
}
|
}
|
||||||
.diff-insert {
|
.diff-insert {
|
||||||
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
|
||||||
color: var(--color-success);
|
color: var(--fs-success);
|
||||||
}
|
}
|
||||||
.diff-equal {
|
.diff-equal {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.diff-marker {
|
.diff-marker {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -468,78 +308,13 @@
|
|||||||
}
|
}
|
||||||
.diff-empty {
|
.diff-empty {
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
.assist-actions {
|
.assist-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
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 {
|
|
||||||
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-radius: var(--radius-md);
|
|
||||||
padding: 1.5rem;
|
|
||||||
max-width: 400px;
|
|
||||||
width: 90%;
|
|
||||||
box-shadow: 0 8px 32px var(--color-shadow);
|
|
||||||
}
|
|
||||||
.modal-title {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
.modal-message {
|
|
||||||
margin: 0 0 1.25rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
.modal-btn {
|
|
||||||
padding: 0.45rem 1rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
color: var(--color-text);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.modal-btn-danger {
|
|
||||||
background: var(--color-danger);
|
|
||||||
color: #fff;
|
|
||||||
border-color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Floating inline assist button (teleported to body) ── */
|
/* ── Floating inline assist button (teleported to body) ── */
|
||||||
.inline-assist-btn {
|
.inline-assist-btn {
|
||||||
@@ -547,10 +322,10 @@
|
|||||||
z-index: 100;
|
z-index: 100;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
padding: 0.3rem 0.75rem;
|
padding: 0.3rem 0.75rem;
|
||||||
background: var(--color-primary);
|
background: var(--fs-action-primary);
|
||||||
color: #fff;
|
color: var(--fs-text-on-action);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
box-shadow: 0 2px 8px var(--color-shadow);
|
box-shadow: 0 2px 8px var(--color-shadow);
|
||||||
@@ -562,12 +337,12 @@
|
|||||||
display: none;
|
display: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -586,7 +361,7 @@
|
|||||||
.sb-label {
|
.sb-label {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
@@ -594,10 +369,10 @@
|
|||||||
.sb-input {
|
.sb-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.35rem 0.5rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -605,11 +380,11 @@
|
|||||||
.sb-select:focus,
|
.sb-select:focus,
|
||||||
.sb-input:focus {
|
.sb-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.sb-divider {
|
.sb-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--color-border);
|
background: var(--fs-border-color);
|
||||||
margin: 0.15rem 0;
|
margin: 0.15rem 0;
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -630,8 +405,8 @@
|
|||||||
width: auto;
|
width: auto;
|
||||||
flex: 0 0 45%;
|
flex: 0 0 45%;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
border-radius: var(--fs-radius-lg) var(--fs-radius-lg) 0 0;
|
||||||
}
|
}
|
||||||
.editor-header {
|
.editor-header {
|
||||||
padding: 0.75rem 1rem 0.5rem;
|
padding: 0.75rem 1rem 0.5rem;
|
||||||
@@ -640,3 +415,96 @@
|
|||||||
padding: 0.5rem 1rem 1rem;
|
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(--fs-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(--fs-action-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back,
|
||||||
|
.btn-clear,
|
||||||
|
.btn-reject,
|
||||||
|
.btn-proofread,
|
||||||
|
.btn-suggest-tags {
|
||||||
|
background: none;
|
||||||
|
border: var(--fs-border);
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
}
|
||||||
|
.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(--fs-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
background: var(--fs-action-destructive);
|
||||||
|
color: var(--fs-text-on-action);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.btn-delete:not(:disabled):hover {
|
||||||
|
background: var(--fs-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(--fs-text-tertiary);
|
||||||
|
padding: 2px var(--fs-space-1);
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.btn-dismiss-tags:hover { color: var(--fs-text-primary); }
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,8 +41,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose pre {
|
.prose pre {
|
||||||
background: var(--color-code-bg);
|
background: var(--fs-surface-code);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose code {
|
.prose code {
|
||||||
background: var(--color-code-inline-bg);
|
background: var(--fs-surface-code-inline);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 0.15rem 0.35rem;
|
padding: 0.15rem 0.35rem;
|
||||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||||
@@ -73,25 +73,25 @@
|
|||||||
|
|
||||||
.prose th,
|
.prose th,
|
||||||
.prose td {
|
.prose td {
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose thead th {
|
.prose thead th {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose tbody tr:nth-child(even) {
|
.prose tbody tr:nth-child(even) {
|
||||||
background: var(--color-table-stripe);
|
background: var(--fs-table-stripe);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose blockquote {
|
.prose blockquote {
|
||||||
border-left: 3px solid var(--color-border);
|
border-left: 3px solid var(--fs-border-color);
|
||||||
margin: 0 0 0.6rem;
|
margin: 0 0 0.6rem;
|
||||||
padding: 0.25rem 0 0.25rem 0.75rem;
|
padding: 0.25rem 0 0.25rem 0.75rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose blockquote p:last-child {
|
.prose blockquote p:last-child {
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
|
|
||||||
.prose hr {
|
.prose hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--fs-border-color);
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose a {
|
.prose a {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,8 +119,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose .inline-tag {
|
.prose .inline-tag {
|
||||||
color: var(--color-tag-text);
|
color: var(--fs-accent);
|
||||||
background: var(--color-tag-bg);
|
background: var(--fs-accent-soft);
|
||||||
padding: 0.1rem 0.35rem;
|
padding: 0.1rem 0.35rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -133,8 +133,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.prose .wikilink {
|
.prose .wikilink {
|
||||||
color: var(--color-wikilink);
|
color: var(--fs-wikilink);
|
||||||
background: var(--color-wikilink-bg);
|
background: var(--fs-accent-soft);
|
||||||
padding: 0.1rem 0.35rem;
|
padding: 0.1rem 0.35rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
|
|
||||||
.prose ul[data-type="taskList"] li > label input[type="checkbox"] {
|
.prose ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
accent-color: var(--color-primary);
|
accent-color: var(--fs-accent);
|
||||||
width: 0.95em;
|
width: 0.95em;
|
||||||
height: 0.95em;
|
height: 0.95em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
|
|
||||||
.prose ul[data-type="taskList"] li[data-checked="true"] > div {
|
.prose ul[data-type="taskList"] li[data-checked="true"] > div {
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Interactive checkboxes — marked output in the list-note viewer */
|
/* Interactive checkboxes — marked output in the list-note viewer */
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
}
|
}
|
||||||
.prose--checklist li input[type="checkbox"] {
|
.prose--checklist li input[type="checkbox"] {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
accent-color: var(--color-primary);
|
accent-color: var(--fs-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 0.95em;
|
width: 0.95em;
|
||||||
height: 0.95em;
|
height: 0.95em;
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
.prose--checklist li:has(input[type="checkbox"]:checked) > p,
|
.prose--checklist li:has(input[type="checkbox"]:checked) > p,
|
||||||
.prose--checklist li:has(input[type="checkbox"]:checked) {
|
.prose--checklist li:has(input[type="checkbox"]:checked) {
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.prose--checklist li:has(input[type="checkbox"]:checked) input[type="checkbox"] {
|
.prose--checklist li:has(input[type="checkbox"]:checked) input[type="checkbox"] {
|
||||||
text-decoration: none; /* don't strike through the checkbox itself */
|
text-decoration: none; /* don't strike through the checkbox itself */
|
||||||
@@ -219,7 +219,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
.tiptap-editor .ProseMirror p.is-editor-empty:first-child::before {
|
||||||
color: var(--color-text-muted, var(--color-text-secondary));
|
color: var(--fs-text-tertiary);
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
float: left;
|
float: left;
|
||||||
height: 0;
|
height: 0;
|
||||||
@@ -227,12 +227,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-wrapper {
|
.tiptap-wrapper {
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tiptap-wrapper:focus-within {
|
.tiptap-wrapper:focus-within {
|
||||||
box-shadow: var(--focus-ring, 0 0 0 2px var(--color-primary));
|
box-shadow: var(--fs-focus-ring);
|
||||||
}
|
}
|
||||||
|
|||||||
+227
-162
@@ -1,149 +1,214 @@
|
|||||||
@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');
|
@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 {
|
:root {
|
||||||
/* Light mode — warm parchment palette */
|
/* accent */
|
||||||
--color-bg: #F5F1E8;
|
--fs-accent: #5B4A8A; /* Scribe's signature colour */
|
||||||
--color-bg-secondary: #FBF8F0;
|
--fs-accent-soft: color-mix(in srgb, var(--fs-accent) 15%, transparent); /* Pill, tag and badge backgrounds */
|
||||||
--color-bg-card: #FBF8F0;
|
--fs-accent-faint: color-mix(in srgb, var(--fs-accent) 8%, transparent); /* The faintest accent wash */
|
||||||
--color-surface: #EFEAE0;
|
--fs-accent-deep: color-mix(in srgb, var(--fs-accent) 70%, black); /* The accent, darkened */
|
||||||
--color-text: #14171A;
|
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 22%, transparent); /* Heaviest accent tint */
|
||||||
--color-text-secondary: #5A5852;
|
--fs-gradient-cta: linear-gradient(135deg, var(--fs-accent), var(--fs-accent-deep));
|
||||||
--color-text-muted: #9A9890;
|
--fs-glow-cta: 0 2px 10px color-mix(in srgb, var(--fs-accent) 35%, transparent);
|
||||||
--color-border: #D9D6CE;
|
--fs-glow-cta-hover: 0 4px 24px color-mix(in srgb, var(--fs-accent) 65%, transparent);
|
||||||
--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);
|
|
||||||
|
|
||||||
/* Action color set — Hybrid rule: action buttons use these, accent reserved for brand moments */
|
/* action */
|
||||||
--color-action-primary: #4A5D3F;
|
--fs-action-primary: #4A5D3F; /* Save, Submit, Confirm */
|
||||||
--color-action-primary-hover: #5A6F4D;
|
--fs-action-secondary: #8B7355; /* Non-destructive alternates */
|
||||||
--color-action-secondary: #8B7355;
|
--fs-action-tertiary: var(--fs-border-color); /* Ghost / outline actions */
|
||||||
--color-action-secondary-hover: #A0876A;
|
--fs-action-destructive: var(--fs-destructive);
|
||||||
--color-action-destructive: #6B2118;
|
--fs-action-primary-hover: color-mix(in srgb, var(--fs-action-primary) 88%, white);
|
||||||
--color-action-destructive-hover: #7E2A1F;
|
--fs-action-secondary-hover: color-mix(in srgb, var(--fs-action-secondary) 88%, white);
|
||||||
--color-action-ghost-border: #3F4651;
|
--fs-action-destructive-hover: color-mix(in srgb, var(--fs-action-destructive) 88%, white);
|
||||||
|
|
||||||
--radius-sm: 6px;
|
/* border */
|
||||||
--radius-md: 12px;
|
--fs-border-color: #3F4651; /* Borders, dividers and ghost outlines */
|
||||||
--radius-lg: 18px;
|
--fs-border: 0.5px solid var(--fs-border-color);
|
||||||
--radius-pill: 9999px;
|
--fs-border-hover: 0.5px solid color-mix(in srgb, var(--fs-text-secondary) 30%, transparent);
|
||||||
--focus-ring: 0 0 0 2px rgba(91, 74, 138, 0.5);
|
--fs-border-active: 2px solid var(--fs-accent); /* Selected card or active tab only */
|
||||||
/* Layout */
|
|
||||||
--page-max-width: 1200px;
|
/* editor */
|
||||||
--page-padding-x: 1rem;
|
--fs-wikilink: var(--fs-accent);
|
||||||
--sidebar-width: 260px;
|
|
||||||
--chat-reading-width: min(1200px, 100%);
|
/* elevation */
|
||||||
--chat-context-sidebar-width: 220px;
|
--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"] {
|
[data-theme="light"] {
|
||||||
/* Dark mode — Obsidian / Iron / Pewter */
|
/* accent */
|
||||||
--color-bg: #14171A;
|
--fs-accent-wash: color-mix(in srgb, var(--fs-accent) 20%, transparent);
|
||||||
--color-bg-secondary: #1E2228;
|
--fs-glow-cta-hover: 0 4px 20px color-mix(in srgb, var(--fs-accent) 55%, transparent);
|
||||||
--color-bg-card: #1E2228;
|
|
||||||
--color-surface: #2C313A;
|
/* border */
|
||||||
--color-text: #E8E4D8;
|
--fs-border-color: #D9D6CE;
|
||||||
--color-text-secondary: #C2BFB4;
|
|
||||||
--color-text-muted: #9C9A92;
|
/* state */
|
||||||
--color-border: #3F4651;
|
--fs-overlay: rgba(0, 0, 0, 0.45);
|
||||||
--color-input-border: #3F4651;
|
|
||||||
--color-primary: #5B4A8A;
|
/* surface */
|
||||||
--color-danger: #C04A1F;
|
--fs-surface-page: #F5F1E8;
|
||||||
--color-tag-bg: rgba(91, 74, 138, 0.15);
|
--fs-surface-raised: #FBF8F0;
|
||||||
--color-tag-text: #5B4A8A;
|
--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
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
The compatibility-alias block that lived here is GONE (#2533). It let ~55
|
||||||
|
components keep their historical --color-* names while theme.css was
|
||||||
|
repointed at the design system; the rename sweep it promised ran on
|
||||||
|
2026-08-08 and every component now references --fs-* directly. Do not
|
||||||
|
reintroduce app-local alias names — the design system's tokens are the
|
||||||
|
vocabulary, and check_snippets_against_design_system can only see through
|
||||||
|
names the system actually declares.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* A VALUE, not an alias — the one survivor of the alias block. The design
|
||||||
|
system has no shadow-colour token yet, so this is a recorded gap: when a
|
||||||
|
second app needs it, promote it to an --fs-* token in the system and
|
||||||
|
regenerate, rather than copying this line. */
|
||||||
--color-shadow: rgba(0, 0, 0, 0.4);
|
--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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Base element styles
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
*::after {
|
*::after {
|
||||||
@@ -152,36 +217,36 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
|
font-family: var(--fs-font-body);
|
||||||
"Segoe UI", Roboto, sans-serif;
|
|
||||||
font-feature-settings: "cv11";
|
font-feature-settings: "cv11";
|
||||||
line-height: 1.5;
|
line-height: var(--fs-leading-body);
|
||||||
transition: background-color 0.2s, color 0.2s;
|
transition: background-color var(--fs-dur-base) var(--fs-ease),
|
||||||
|
color var(--fs-dur-base) var(--fs-ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2 {
|
h1, h2 {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: var(--fs-font-display);
|
||||||
font-optical-sizing: auto;
|
font-optical-sizing: auto;
|
||||||
font-weight: 500;
|
font-weight: var(--fs-weight-medium);
|
||||||
line-height: 1.3;
|
line-height: var(--fs-leading-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
font-family: var(--fs-font-body);
|
||||||
font-weight: 500;
|
font-weight: var(--fs-weight-medium);
|
||||||
line-height: 1.3;
|
line-height: var(--fs-leading-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
code, pre, kbd, samp {
|
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";
|
font-feature-settings: "liga", "calt";
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: rgba(91, 74, 138, 0.3);
|
background: var(--fs-accent-wash);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus-visible,
|
input:focus-visible,
|
||||||
@@ -190,15 +255,15 @@ select:focus-visible,
|
|||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
a:focus-visible {
|
a:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: var(--focus-ring);
|
box-shadow: var(--fs-focus-ring);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:not(:disabled):active,
|
button:not(:disabled):active,
|
||||||
.btn:not(:disabled):active,
|
.btn:not(:disabled):active,
|
||||||
[role="button"]:not(:disabled):active {
|
[role="button"]:not(:disabled):active {
|
||||||
transform: scale(0.97);
|
transform: scale(0.97);
|
||||||
transition: transform 0.08s ease;
|
transition: transform 0.08s var(--fs-ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive breakpoints: 480px (phone), 768px (tablet), 1024px (desktop) */
|
/* Responsive breakpoints: 480px (phone), 768px (tablet), 1024px (desktop) */
|
||||||
@@ -228,11 +293,11 @@ button:not(:disabled):active,
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: var(--color-border);
|
background: var(--fs-border-color);
|
||||||
border-radius: 9999px;
|
border-radius: var(--fs-radius-pill);
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: var(--color-text-muted);
|
background: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Floating inline assist button (teleported to body, cannot be scoped) */
|
/* Floating inline assist button (teleported to body, cannot be scoped) */
|
||||||
@@ -240,14 +305,14 @@ button:not(:disabled):active,
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 150;
|
z-index: 150;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
background: var(--color-primary);
|
background: var(--fs-accent);
|
||||||
color: #fff;
|
color: var(--fs-text-primary);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 999px;
|
border-radius: var(--fs-radius-pill);
|
||||||
padding: 0.3rem 0.8rem;
|
padding: 0.3rem 0.8rem;
|
||||||
font-size: 0.8rem;
|
font-size: var(--fs-size-body-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 2px 8px var(--color-shadow);
|
box-shadow: var(--fs-shadow-2);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.inline-assist-btn:hover {
|
.inline-assist-btn:hover {
|
||||||
|
|||||||
@@ -15,27 +15,27 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.ctx-crumb-parent {
|
.ctx-crumb-parent {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.ctx-crumb-parent:hover {
|
.ctx-crumb-parent:hover {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.ctx-crumb-project {
|
.ctx-crumb-project {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
||||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.ctx-crumb-project:hover {
|
.ctx-crumb-project:hover {
|
||||||
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 18%, transparent);
|
||||||
}
|
}
|
||||||
.ctx-crumb-milestone {
|
.ctx-crumb-milestone {
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,9 +47,15 @@ router.afterEach(() => {
|
|||||||
<div class="nav-pill-bar">
|
<div class="nav-pill-bar">
|
||||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
<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="/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="/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="/rules" class="nav-link">Rulebooks</router-link>
|
||||||
|
<!-- A design system is a RECORD you author, not a setting. It sat in
|
||||||
|
the utility cluster with Trash and Settings while /design was a
|
||||||
|
read-only gallery, and stayed there after it became a record type
|
||||||
|
with its own table, sharing and MCP tools. Content, by the same
|
||||||
|
rule that puts Snippets and Rulebooks here. -->
|
||||||
|
<router-link to="/design-systems" class="nav-link">Design</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,9 +99,10 @@ router.afterEach(() => {
|
|||||||
<div v-if="mobileMenuOpen" class="mobile-menu">
|
<div v-if="mobileMenuOpen" class="mobile-menu">
|
||||||
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
|
<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="/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="/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="/rules" class="nav-link">Rulebooks</router-link>
|
||||||
|
<router-link to="/design-systems" class="nav-link">Design</router-link>
|
||||||
<router-link to="/shared" class="nav-link">Shared</router-link>
|
<router-link to="/shared" class="nav-link">Shared</router-link>
|
||||||
<div class="mobile-divider"></div>
|
<div class="mobile-divider"></div>
|
||||||
<router-link to="/trash" class="nav-link">Trash</router-link>
|
<router-link to="/trash" class="nav-link">Trash</router-link>
|
||||||
@@ -117,20 +124,35 @@ router.afterEach(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.app-header {
|
.app-header {
|
||||||
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
|
background: linear-gradient(180deg, var(--fs-surface-hover), var(--fs-surface-page));
|
||||||
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
|
border-bottom: 1px solid color-mix(in srgb, var(--fs-accent) 18%, transparent);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
/* Three tracks, not a flex row with an absolutely-centred overlay.
|
||||||
|
*
|
||||||
|
* The pill bar used to be `position: absolute; left: 50%`, which meant it did
|
||||||
|
* not participate in layout: when the header ran out of room it OVERLAPPED the
|
||||||
|
* brand and the utility cluster rather than pushing them, and nothing wrapped
|
||||||
|
* or scrolled to signal it. A sixth link reached that point at ~1270px, which
|
||||||
|
* is an ordinary window on any monitor.
|
||||||
|
*
|
||||||
|
* `1fr auto 1fr` fixes it structurally. A `1fr` track has an AUTO minimum, so
|
||||||
|
* neither side can be squeezed below its content, and the two side tracks stay
|
||||||
|
* equal to each other — which is what keeps the bar centred in the viewport
|
||||||
|
* rather than merely centred in the leftover space. Overflow becomes the
|
||||||
|
* header growing, not two things sharing pixels. */
|
||||||
.nav {
|
.nav {
|
||||||
padding: 0.6rem 1.5rem;
|
padding: 0.6rem 1.5rem;
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 0.75rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Left — brand */
|
/* Left — brand */
|
||||||
.nav-brand {
|
.nav-brand {
|
||||||
|
justify-self: start;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
@@ -148,9 +170,7 @@ router.afterEach(() => {
|
|||||||
|
|
||||||
/* Center — pill bar */
|
/* Center — pill bar */
|
||||||
.nav-center {
|
.nav-center {
|
||||||
position: absolute;
|
justify-self: center;
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -158,21 +178,23 @@ router.afterEach(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
background: var(--color-primary-faint);
|
background: var(--fs-accent-faint);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Right */
|
/* Right */
|
||||||
.nav-right {
|
.nav-right {
|
||||||
|
justify-self: end;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
padding: 0.3rem 0.75rem;
|
padding: 0.3rem 0.75rem;
|
||||||
@@ -180,14 +202,14 @@ router.afterEach(() => {
|
|||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
background: var(--color-primary-tint);
|
background: var(--fs-accent-soft);
|
||||||
}
|
}
|
||||||
.nav-link.router-link-active {
|
.nav-link.router-link-active {
|
||||||
color: var(--color-primary-solid);
|
color: var(--fs-accent);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: rgba(91, 74, 138, 0.25);
|
background: color-mix(in srgb, var(--fs-accent) 25%, transparent);
|
||||||
box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
|
box-shadow: 0 0 16px color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status indicator */
|
/* Status indicator */
|
||||||
@@ -207,7 +229,7 @@ router.afterEach(() => {
|
|||||||
.status-text {
|
.status-text {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
/* Status dots are indicator lights, not semantic-palette buttons —
|
/* Status dots are indicator lights, not semantic-palette buttons —
|
||||||
they want to read as vital (Moss/Warning/Error are too muted for
|
they want to read as vital (Moss/Warning/Error are too muted for
|
||||||
@@ -217,7 +239,7 @@ router.afterEach(() => {
|
|||||||
.status-yellow .status-dot { background: #facc15; animation: pulse-dot 2s infinite; }
|
.status-yellow .status-dot { background: #facc15; animation: pulse-dot 2s infinite; }
|
||||||
.status-orange .status-dot { background: #f97316; }
|
.status-orange .status-dot { background: #f97316; }
|
||||||
.status-red .status-dot { background: #ef4444; }
|
.status-red .status-dot { background: #ef4444; }
|
||||||
.status-gray .status-dot { background: var(--color-text-muted); animation: pulse-dot 2s infinite; }
|
.status-gray .status-dot { background: var(--fs-text-tertiary); animation: pulse-dot 2s infinite; }
|
||||||
@keyframes pulse-dot {
|
@keyframes pulse-dot {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.3; }
|
50% { opacity: 0.3; }
|
||||||
@@ -230,12 +252,12 @@ router.afterEach(() => {
|
|||||||
/* Icon buttons (?, theme, gear) */
|
/* Icon buttons (?, theme, gear) */
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
padding: 0.25rem 0.45rem;
|
padding: 0.25rem 0.45rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -243,9 +265,9 @@ router.afterEach(() => {
|
|||||||
}
|
}
|
||||||
.btn-icon:hover,
|
.btn-icon:hover,
|
||||||
.btn-icon.active {
|
.btn-icon.active {
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* User info */
|
/* User info */
|
||||||
@@ -255,36 +277,42 @@ router.afterEach(() => {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
border-left: 1px solid var(--color-border);
|
border-left: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
.username {
|
.username {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
/* The widest thing on the right and the only one that can give: a long
|
||||||
|
username shouldn't be what decides where the nav bar sits. */
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 12ch;
|
||||||
}
|
}
|
||||||
.admin-badge {
|
.admin-badge {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||||
padding: 0.1rem 0.35rem;
|
padding: 0.1rem 0.35rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
}
|
}
|
||||||
.btn-logout {
|
.btn-logout {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.btn-logout:hover {
|
.btn-logout:hover {
|
||||||
color: var(--color-danger);
|
color: var(--fs-error);
|
||||||
border-color: var(--color-danger);
|
border-color: var(--fs-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hamburger — mobile only */
|
/* Hamburger — mobile only */
|
||||||
@@ -302,7 +330,7 @@ router.afterEach(() => {
|
|||||||
display: block;
|
display: block;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: var(--color-text);
|
background: var(--fs-text-primary);
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,13 +339,13 @@ router.afterEach(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0.5rem 1rem 0.75rem;
|
padding: 0.5rem 1rem 0.75rem;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--fs-border-color);
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
gap: 0.1rem;
|
gap: 0.1rem;
|
||||||
}
|
}
|
||||||
.mobile-divider {
|
.mobile-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--color-border);
|
background: var(--fs-border-color);
|
||||||
margin: 0.4rem 0;
|
margin: 0.4rem 0;
|
||||||
}
|
}
|
||||||
.mobile-actions {
|
.mobile-actions {
|
||||||
@@ -331,10 +359,25 @@ router.afterEach(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding-top: 0.4rem;
|
padding-top: 0.4rem;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--fs-border-color);
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The grid above means running out of room can no longer cause a collision —
|
||||||
|
but it can still make the header wider than the window, and a horizontally
|
||||||
|
scrolling header is its own defect. So shed width before that happens. The
|
||||||
|
wordmark goes first: the logo beside it says the same thing and is still the
|
||||||
|
link home. */
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.brand-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.nav-link {
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.nav-center {
|
.nav-center {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -355,7 +398,7 @@ router.afterEach(() => {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.mobile-menu .nav-link.router-link-active {
|
.mobile-menu .nav-link.router-link-active {
|
||||||
background: var(--color-primary-wash);
|
background: var(--fs-accent-wash);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.mobile-user .btn-logout {
|
.mobile-user .btn-logout {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ defineProps<{ size?: number }>();
|
|||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
|
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
|
||||||
<stop offset="0%" stop-color="var(--color-primary-solid)" />
|
<stop offset="0%" stop-color="var(--fs-accent)" />
|
||||||
<stop offset="100%" stop-color="var(--color-primary-deep)" />
|
<stop offset="100%" stop-color="var(--fs-accent-deep)" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<!-- Book body -->
|
<!-- Book body -->
|
||||||
@@ -44,12 +44,12 @@ defineProps<{ size?: number }>();
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.logo-book {
|
.logo-book {
|
||||||
fill: url(#logo-gradient);
|
fill: url(#logo-gradient);
|
||||||
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
|
stroke: color-mix(in srgb, var(--fs-accent) 70%, transparent);
|
||||||
}
|
}
|
||||||
.logo-spine {
|
.logo-spine {
|
||||||
stroke: var(--color-text-secondary);
|
stroke: var(--fs-text-secondary);
|
||||||
}
|
}
|
||||||
.logo-lines {
|
.logo-lines {
|
||||||
stroke: var(--color-text-muted);
|
stroke: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ function markerFor(type: DiffLine['type']): string {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,15 +103,15 @@ function markerFor(type: DiffLine['type']): string {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-summary-ins { color: var(--color-success, #2ecc71); }
|
.diff-summary-ins { color: var(--fs-success); }
|
||||||
.diff-summary-del { color: var(--color-danger, #e74c3c); }
|
.diff-summary-del { color: var(--fs-error); }
|
||||||
|
|
||||||
.diff-scroll {
|
.diff-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -123,7 +123,7 @@ function markerFor(type: DiffLine['type']): string {
|
|||||||
.diff-empty {
|
.diff-empty {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-line {
|
.diff-line {
|
||||||
@@ -136,24 +136,24 @@ function markerFor(type: DiffLine['type']): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.diff-delete {
|
.diff-delete {
|
||||||
background: color-mix(in srgb, var(--color-danger, #e74c3c) 12%, transparent);
|
background: color-mix(in srgb, var(--fs-error) 12%, transparent);
|
||||||
color: var(--color-danger, #e74c3c);
|
color: var(--fs-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-insert {
|
.diff-insert {
|
||||||
background: color-mix(in srgb, var(--color-success, #2ecc71) 12%, transparent);
|
background: color-mix(in srgb, var(--fs-success) 12%, transparent);
|
||||||
color: var(--color-success, #2ecc71);
|
color: var(--fs-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-equal {
|
.diff-equal {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.diff-collapse {
|
.diff-collapse {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
border-top: 1px dashed var(--color-border);
|
border-top: 1px dashed var(--fs-border-color);
|
||||||
border-bottom: 1px dashed var(--color-border);
|
border-bottom: 1px dashed var(--fs-border-color);
|
||||||
padding-top: 0.2rem;
|
padding-top: 0.2rem;
|
||||||
padding-bottom: 0.2rem;
|
padding-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
|
|||||||
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
|
import { apiGet, pinNoteVersion, unpinNoteVersion } from "@/api/client";
|
||||||
import DiffView from "@/components/DiffView.vue";
|
import DiffView from "@/components/DiffView.vue";
|
||||||
import type { DiffLine } from "@/composables/useAssist";
|
import type { DiffLine } from "@/composables/useAssist";
|
||||||
|
import { fmtStamp } from "@/utils/dateFormat";
|
||||||
|
|
||||||
interface NoteVersion {
|
interface NoteVersion {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -56,14 +57,6 @@ const diff = computed<DiffLine[]>(() => {
|
|||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleString(undefined, {
|
|
||||||
month: 'short', day: 'numeric', year: 'numeric',
|
|
||||||
hour: '2-digit', minute: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadVersions() {
|
async function loadVersions() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -212,7 +205,7 @@ onMounted(loadVersions);
|
|||||||
v-if="v.pin_kind === 'manual' && v.pin_label"
|
v-if="v.pin_kind === 'manual' && v.pin_label"
|
||||||
class="history-item-label"
|
class="history-item-label"
|
||||||
>{{ v.pin_label }}</div>
|
>{{ v.pin_label }}</div>
|
||||||
<div class="history-item-date">{{ formatDate(v.created_at) }}</div>
|
<div class="history-item-date">{{ fmtStamp(v.created_at) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -281,7 +274,7 @@ onMounted(loadVersions);
|
|||||||
|
|
||||||
<div class="history-footer">
|
<div class="history-footer">
|
||||||
<button
|
<button
|
||||||
class="btn-restore"
|
class="btn-primary"
|
||||||
:disabled="!selectedVersion?.body"
|
:disabled="!selectedVersion?.body"
|
||||||
@click="restore"
|
@click="restore"
|
||||||
>Restore this version</button>
|
>Restore this version</button>
|
||||||
@@ -309,7 +302,7 @@ onMounted(loadVersions);
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.9rem 1.25rem;
|
padding: 0.9rem 1.25rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-title {
|
.history-title {
|
||||||
@@ -322,11 +315,11 @@ onMounted(loadVersions);
|
|||||||
border: none;
|
border: none;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0.1rem 0.3rem;
|
padding: 0.1rem 0.3rem;
|
||||||
}
|
}
|
||||||
.history-close:hover { color: var(--color-text); }
|
.history-close:hover { color: var(--fs-text-primary); }
|
||||||
|
|
||||||
.history-body {
|
.history-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -338,7 +331,7 @@ onMounted(loadVersions);
|
|||||||
.history-list {
|
.history-list {
|
||||||
width: 220px;
|
width: 220px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-right: 1px solid var(--color-border);
|
border-right: 1px solid var(--fs-border-color);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,18 +339,18 @@ onMounted(loadVersions);
|
|||||||
padding: 0.6rem 0.9rem;
|
padding: 0.6rem 0.9rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
.history-item:hover { background: var(--color-bg-secondary); }
|
.history-item:hover { background: var(--fs-surface-raised); }
|
||||||
.history-item.selected {
|
.history-item.selected {
|
||||||
border-left-color: var(--color-primary);
|
border-left-color: var(--fs-accent);
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-item-title {
|
.history-item-title {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -365,7 +358,7 @@ onMounted(loadVersions);
|
|||||||
|
|
||||||
.history-item-date {
|
.history-item-date {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,7 +374,7 @@ onMounted(loadVersions);
|
|||||||
.history-empty {
|
.history-empty {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-footer {
|
.history-footer {
|
||||||
@@ -390,22 +383,9 @@ onMounted(loadVersions);
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
padding: 0.75rem 1.25rem;
|
padding: 0.75rem 1.25rem;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 badges + label rendering ───────────────────────────────────────── */
|
||||||
.pin-badge {
|
.pin-badge {
|
||||||
@@ -416,12 +396,12 @@ onMounted(loadVersions);
|
|||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.pin-badge-manual { color: var(--color-primary, #6366f1); }
|
.pin-badge-manual { color: var(--fs-accent); }
|
||||||
.pin-badge-auto { color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); }
|
.pin-badge-auto { color: var(--fs-text-tertiary); }
|
||||||
|
|
||||||
.history-item-label {
|
.history-item-label {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--color-primary, #6366f1);
|
color: var(--fs-accent);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -432,7 +412,7 @@ onMounted(loadVersions);
|
|||||||
/* ── Pin controls above the diff ────────────────────────────────────────── */
|
/* ── Pin controls above the diff ────────────────────────────────────────── */
|
||||||
.version-pin-controls {
|
.version-pin-controls {
|
||||||
padding: 0.4rem 0.5rem 0.5rem;
|
padding: 0.4rem 0.5rem 0.5rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
.pin-actions {
|
.pin-actions {
|
||||||
@@ -443,7 +423,7 @@ onMounted(loadVersions);
|
|||||||
}
|
}
|
||||||
.pin-state {
|
.pin-state {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
|
color: var(--fs-text-tertiary);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -455,13 +435,13 @@ onMounted(loadVersions);
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
|
.btn-pin:hover:not(:disabled), .btn-pin-edit:hover:not(:disabled) {
|
||||||
background: rgba(99, 102, 241, 0.12);
|
background: rgba(99, 102, 241, 0.12);
|
||||||
border-color: var(--color-primary, #6366f1);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.btn-unpin:hover:not(:disabled) {
|
.btn-unpin:hover:not(:disabled) {
|
||||||
background: rgba(239, 68, 68, 0.10);
|
background: rgba(239, 68, 68, 0.10);
|
||||||
@@ -476,27 +456,27 @@ onMounted(loadVersions);
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.3rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
background: var(--color-input-bg, rgba(255, 255, 255, 0.03));
|
background: var(--fs-surface-page);
|
||||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm, 4px);
|
border-radius: var(--fs-radius-sm);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
.pin-label-input:focus {
|
.pin-label-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary, #6366f1);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.btn-pin-save, .btn-pin-cancel {
|
.btn-pin-save, .btn-pin-cancel {
|
||||||
padding: 0.3rem 0.7rem;
|
padding: 0.3rem 0.7rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm, 4px);
|
border-radius: var(--fs-radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-pin-save:hover:not(:disabled) {
|
.btn-pin-save:hover:not(:disabled) {
|
||||||
background: rgba(99, 102, 241, 0.12);
|
background: rgba(99, 102, 241, 0.12);
|
||||||
border-color: var(--color-primary, #6366f1);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
|
.btn-pin-save:disabled, .btn-pin-cancel:disabled,
|
||||||
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
|
.btn-pin:disabled, .btn-pin-edit:disabled, .btn-unpin:disabled {
|
||||||
|
|||||||
@@ -74,11 +74,11 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.iap {
|
.iap {
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header ── */
|
/* ── Header ── */
|
||||||
@@ -88,16 +88,16 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.45rem 0.75rem;
|
padding: 0.45rem 0.75rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Streaming ── */
|
/* ── Streaming ── */
|
||||||
.iap-streaming {
|
.iap-streaming {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.iap-streaming .iap-header {
|
.iap-streaming .iap-header {
|
||||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
|
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
|
||||||
}
|
}
|
||||||
|
|
||||||
.iap-pulse {
|
.iap-pulse {
|
||||||
@@ -105,7 +105,7 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--color-primary);
|
background: var(--fs-accent);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
animation: iap-pulse 1.2s ease-in-out infinite;
|
animation: iap-pulse 1.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
@@ -117,7 +117,7 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
.iap-label {
|
.iap-label {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -125,9 +125,9 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
|
|
||||||
.iap-btn-cancel {
|
.iap-btn-cancel {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -135,8 +135,8 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.iap-btn-cancel:hover {
|
.iap-btn-cancel:hover {
|
||||||
border-color: var(--color-danger, #e74c3c);
|
border-color: var(--fs-error);
|
||||||
color: var(--color-danger, #e74c3c);
|
color: var(--fs-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iap-stream-preview {
|
.iap-stream-preview {
|
||||||
@@ -150,29 +150,29 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
.iap-waiting {
|
.iap-waiting {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Review ── */
|
/* ── Review ── */
|
||||||
.iap-review-title {
|
.iap-review-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iap-btn-toggle {
|
.iap-btn-toggle {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.iap-btn-toggle:hover {
|
.iap-btn-toggle:hover {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iap-actions {
|
.iap-actions {
|
||||||
@@ -183,27 +183,27 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
.iap-btn-accept,
|
.iap-btn-accept,
|
||||||
.iap-btn-reject {
|
.iap-btn-reject {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
padding: 0.2rem 0.65rem;
|
padding: 0.2rem 0.65rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-weight: 600;
|
font-weight: var(--fs-weight-medium);
|
||||||
}
|
}
|
||||||
.iap-btn-accept {
|
.iap-btn-accept {
|
||||||
background: var(--color-success, #22c55e);
|
background: var(--fs-success);
|
||||||
color: #fff;
|
color: var(--fs-text-on-action);
|
||||||
}
|
}
|
||||||
.iap-btn-accept:hover { opacity: 0.85; }
|
.iap-btn-accept:hover { opacity: 0.85; }
|
||||||
|
|
||||||
.iap-btn-reject {
|
.iap-btn-reject {
|
||||||
background: var(--color-bg-card, var(--color-bg));
|
background: var(--fs-surface-raised);
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
.iap-btn-reject:hover {
|
.iap-btn-reject:hover {
|
||||||
border-color: var(--color-danger, #e74c3c);
|
border-color: var(--fs-error);
|
||||||
color: var(--color-danger, #e74c3c);
|
color: var(--fs-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Diff ── */
|
/* ── Diff ── */
|
||||||
@@ -224,14 +224,14 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iap-diff-equal { color: var(--color-text-muted); }
|
.iap-diff-equal { color: var(--fs-text-tertiary); }
|
||||||
.iap-diff-delete {
|
.iap-diff-delete {
|
||||||
background: color-mix(in srgb, var(--color-danger, #e74c3c) 10%, transparent);
|
background: color-mix(in srgb, var(--fs-error) 10%, transparent);
|
||||||
color: var(--color-danger, #e74c3c);
|
color: var(--fs-error);
|
||||||
}
|
}
|
||||||
.iap-diff-insert {
|
.iap-diff-insert {
|
||||||
background: color-mix(in srgb, var(--color-success, #22c55e) 10%, transparent);
|
background: color-mix(in srgb, var(--fs-success) 10%, transparent);
|
||||||
color: var(--color-success, #22c55e);
|
color: var(--fs-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.iap-diff-marker {
|
.iap-diff-marker {
|
||||||
@@ -243,7 +243,7 @@ const markers: Record<DiffLine["type"], string> = {
|
|||||||
|
|
||||||
.iap-diff-empty {
|
.iap-diff-empty {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,9 +111,9 @@ const groups = [
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--fs-radius-lg);
|
||||||
padding: 3px 4px;
|
padding: 3px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ const groups = [
|
|||||||
display: block;
|
display: block;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background: var(--color-border);
|
background: var(--fs-border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin: 0 3px;
|
margin: 0 3px;
|
||||||
}
|
}
|
||||||
@@ -141,7 +141,7 @@ const groups = [
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
|
transition: background 0.12s, color 0.12s, box-shadow 0.12s;
|
||||||
@@ -149,19 +149,19 @@ const groups = [
|
|||||||
}
|
}
|
||||||
|
|
||||||
.md-btn:hover {
|
.md-btn:hover {
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-btn.active {
|
.md-btn.active {
|
||||||
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 14%, transparent);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 35%, transparent);
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--fs-accent) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-btn.active:hover {
|
.md-btn.active:hover {
|
||||||
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 22%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
|
|||||||
@@ -66,10 +66,10 @@ function onChange(e: Event) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.milestone-select {
|
.milestone-select {
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -77,7 +77,7 @@ function onChange(e: Event) {
|
|||||||
}
|
}
|
||||||
.milestone-select:focus {
|
.milestone-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.milestone-select:disabled {
|
.milestone-select:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
|||||||
@@ -60,15 +60,15 @@ function goEdit() {
|
|||||||
.note-card {
|
.note-card {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--fs-radius-lg);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
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(--fs-accent) 6%, transparent);
|
||||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||||
}
|
}
|
||||||
.note-card:hover {
|
.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(--fs-accent) 14.0%, transparent);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,18 +78,18 @@ function goEdit() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.45rem 0.75rem;
|
padding: 0.45rem 0.75rem;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
.note-card.compact:first-child {
|
.note-card.compact:first-child {
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
.note-card.compact:hover {
|
.note-card.compact:hover {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
background: rgba(91, 74, 138, 0.04);
|
background: color-mix(in srgb, var(--fs-accent) 4%, transparent);
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
.note-title-compact {
|
.note-title-compact {
|
||||||
@@ -108,7 +108,7 @@ function goEdit() {
|
|||||||
}
|
}
|
||||||
.timestamp-compact {
|
.timestamp-compact {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -133,20 +133,20 @@ function goEdit() {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0.25rem 0.6rem;
|
padding: 0.25rem 0.6rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.15s, border-color 0.15s;
|
transition: color 0.15s, border-color 0.15s;
|
||||||
}
|
}
|
||||||
.btn-edit:hover {
|
.btn-edit:hover {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.note-preview {
|
.note-preview {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
max-height: 7.5em;
|
max-height: 7.5em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -163,6 +163,6 @@ function goEdit() {
|
|||||||
.timestamp {
|
.timestamp {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -60,11 +60,11 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.btn-bell {
|
.btn-bell {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
padding: 0.25rem 0.45rem;
|
padding: 0.25rem 0.45rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -72,17 +72,17 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.btn-bell:hover,
|
.btn-bell:hover,
|
||||||
.btn-bell.active {
|
.btn-bell.active {
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bell-badge {
|
.bell-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: -5px;
|
top: -5px;
|
||||||
right: -5px;
|
right: -5px;
|
||||||
background: var(--color-danger, #ef4444);
|
background: var(--fs-error);
|
||||||
color: #fff;
|
color: var(--fs-text-on-action);
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
min-width: 16px;
|
min-width: 16px;
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ onMounted(() => store.fetchAll())
|
|||||||
<span class="notif-panel-title">Notifications</span>
|
<span class="notif-panel-title">Notifications</span>
|
||||||
<button
|
<button
|
||||||
v-if="store.count > 0"
|
v-if="store.count > 0"
|
||||||
class="btn-mark-all"
|
class="btn-text"
|
||||||
@click="store.markAll()"
|
@click="store.markAll()"
|
||||||
>Mark all read</button>
|
>Mark all read</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -70,7 +70,7 @@ onMounted(() => store.fetchAll())
|
|||||||
</p>
|
</p>
|
||||||
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
|
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
|
||||||
</div>
|
</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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div v-else class="notif-empty">No unread notifications</div>
|
<div v-else class="notif-empty">No unread notifications</div>
|
||||||
@@ -85,9 +85,9 @@ onMounted(() => store.fetchAll())
|
|||||||
width: 340px;
|
width: 340px;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--color-surface);
|
background: var(--fs-surface-hover);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--fs-radius-xl);
|
||||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.18);
|
||||||
z-index: 500;
|
z-index: 500;
|
||||||
}
|
}
|
||||||
@@ -97,10 +97,10 @@ onMounted(() => store.fetchAll())
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
background: var(--color-surface);
|
background: var(--fs-surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-panel-title {
|
.notif-panel-title {
|
||||||
@@ -108,33 +108,18 @@ onMounted(() => store.fetchAll())
|
|||||||
font-size: 0.9rem;
|
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 {
|
.notif-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
.notif-item:last-child { border-bottom: none; }
|
.notif-item:last-child { border-bottom: none; }
|
||||||
.notif-item:hover { background: var(--color-hover); }
|
.notif-item:hover { background: var(--fs-surface-hover); }
|
||||||
|
|
||||||
.notif-icon { font-size: 1.2rem; flex-shrink: 0; margin-top: 0.1rem; }
|
.notif-icon { font-size: 1.2rem; flex-shrink: 0; margin-top: 0.1rem; }
|
||||||
|
|
||||||
@@ -142,28 +127,10 @@ onMounted(() => store.fetchAll())
|
|||||||
.notif-msg {
|
.notif-msg {
|
||||||
margin: 0 0 0.2rem;
|
margin: 0 0 0.2rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.notif-time { font-size: 0.75rem; color: var(--color-muted); }
|
.notif-time { font-size: 0.75rem; color: var(--fs-text-tertiary); }
|
||||||
|
|
||||||
.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>
|
</style>
|
||||||
|
|||||||
@@ -74,27 +74,27 @@ function goToPage(page: number) {
|
|||||||
}
|
}
|
||||||
.page-btn {
|
.page-btn {
|
||||||
padding: 0.35rem 0.7rem;
|
padding: 0.35rem 0.7rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.page-btn:hover:not(:disabled) {
|
.page-btn:hover:not(:disabled) {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
}
|
}
|
||||||
.page-btn:disabled {
|
.page-btn:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.page-btn.active {
|
.page-btn.active {
|
||||||
background: var(--color-primary);
|
background: var(--fs-accent);
|
||||||
color: #fff;
|
color: var(--fs-text-on-action);
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.ellipsis {
|
.ellipsis {
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -33,15 +33,15 @@ const labels: Record<TaskPriority, string> = {
|
|||||||
letter-spacing: 0.025em;
|
letter-spacing: 0.025em;
|
||||||
}
|
}
|
||||||
.priority-low {
|
.priority-low {
|
||||||
background: var(--color-priority-low-bg);
|
background: var(--fs-priority-low-bg);
|
||||||
color: var(--color-priority-low);
|
color: var(--fs-priority-low);
|
||||||
}
|
}
|
||||||
.priority-medium {
|
.priority-medium {
|
||||||
background: var(--color-priority-medium-bg);
|
background: var(--fs-priority-medium-bg);
|
||||||
color: var(--color-priority-medium);
|
color: var(--fs-priority-medium);
|
||||||
}
|
}
|
||||||
.priority-high {
|
.priority-high {
|
||||||
background: var(--color-priority-high-bg);
|
background: var(--fs-priority-high-bg);
|
||||||
color: var(--color-priority-high);
|
color: var(--fs-priority-high);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* A project's own code, checked against the design system it is bound to (#2432).
|
||||||
|
*
|
||||||
|
* This is what the design surface is FOR: a project's recorded components
|
||||||
|
* measured against the sheet they are supposed to use. The check itself is not
|
||||||
|
* new — `check_snippets_against_system` has taken a project id since it was
|
||||||
|
* written, and the route has always read `?project_id=`. Nothing on this side
|
||||||
|
* ever passed one, so the capability shipped and stayed unreachable.
|
||||||
|
*
|
||||||
|
* The finding that matters most is the quiet one. `local_definitions` is a
|
||||||
|
* snippet minting its own custom property instead of reaching for the shared
|
||||||
|
* one — the codebase re-solving a solved problem, one component at a time.
|
||||||
|
* Nothing breaks, no test fails, and the duplication only becomes visible when
|
||||||
|
* someone changes the shared value and half the components don't move.
|
||||||
|
*
|
||||||
|
* SCOPE, and it is a limit rather than an omission: this reads RECORDED code —
|
||||||
|
* snippets — because that is the code Scribe holds. A repository's own sources
|
||||||
|
* are checked where they live, by that project's CI.
|
||||||
|
*/
|
||||||
|
import { onMounted, ref, watch } from "vue";
|
||||||
|
|
||||||
|
import { checkSnippets, type SnippetCheck } from "@/api/designSystems";
|
||||||
|
|
||||||
|
const props = defineProps<{ projectId: number; designSystemId: number | null }>();
|
||||||
|
|
||||||
|
const check = ref<SnippetCheck | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const failed = ref(false);
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
check.value = null;
|
||||||
|
failed.value = false;
|
||||||
|
if (props.designSystemId === null) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
check.value = await checkSnippets(props.designSystemId, props.projectId);
|
||||||
|
} catch {
|
||||||
|
// Said out loud rather than rendered as an empty result. "Couldn't check"
|
||||||
|
// and "nothing to report" look identical if you let them, and that is how
|
||||||
|
// a check comes to sit dead without anyone noticing (#2419).
|
||||||
|
failed.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(run);
|
||||||
|
watch(() => [props.projectId, props.designSystemId], run);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pdt">
|
||||||
|
<div v-if="designSystemId === null" class="pdt-note">
|
||||||
|
<strong>No design system for this project.</strong>
|
||||||
|
<p>
|
||||||
|
Bind one in the sidebar and this tab reports where the project's recorded
|
||||||
|
components disagree with it — references to tokens the system doesn't
|
||||||
|
have, literals it says to stop writing, and properties a component mints
|
||||||
|
for itself instead of reusing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="loading" class="pdt-muted">Checking this project's snippets…</p>
|
||||||
|
|
||||||
|
<div v-else-if="failed" class="pdt-note">
|
||||||
|
<strong>The check couldn't run.</strong>
|
||||||
|
<p>Nothing was compared — this is a failure, not a clean result.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="check">
|
||||||
|
<p v-if="!check.checked" class="pdt-muted">
|
||||||
|
This project has no recorded snippets, so nothing was checked. Record the
|
||||||
|
components you reuse and they get measured against the sheet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-else-if="!check.findings.length" class="pdt-clean">
|
||||||
|
{{ check.checked }} snippet{{ check.checked === 1 ? "" : "s" }} checked —
|
||||||
|
every reference resolves, and none mints a property of its own.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<p class="pdt-summary">
|
||||||
|
<strong>{{ check.findings.length }}</strong> of {{ check.checked }}
|
||||||
|
snippet{{ check.checked === 1 ? "" : "s" }} disagree with the sheet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="pdt-list">
|
||||||
|
<li v-for="f in check.findings" :key="f.snippet_id" class="pdt-finding">
|
||||||
|
<router-link :to="`/snippets/${f.snippet_id}`" class="pdt-title">
|
||||||
|
{{ f.title || "Untitled snippet" }}
|
||||||
|
</router-link>
|
||||||
|
|
||||||
|
<!-- Renders as nothing at all: no error, no failing test, just an
|
||||||
|
element that quietly isn't styled. Leads for that reason. -->
|
||||||
|
<div v-if="f.unknown.length" class="pdt-row">
|
||||||
|
<span class="pdt-tag unknown">no such token</span>
|
||||||
|
<span class="pdt-detail">
|
||||||
|
<code v-for="name in f.unknown" :key="name">{{ name }}</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="f.local_definitions.length" class="pdt-row">
|
||||||
|
<span class="pdt-tag local">defines its own</span>
|
||||||
|
<span class="pdt-detail">
|
||||||
|
<code v-for="name in f.local_definitions" :key="name">{{ name }}</code>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="f.superseded_literals.length" class="pdt-row">
|
||||||
|
<span class="pdt-tag superseded">write the token</span>
|
||||||
|
<span class="pdt-detail">
|
||||||
|
<span v-for="s in f.superseded_literals" :key="s.literal" class="pdt-swap">
|
||||||
|
<code>{{ s.literal }}</code> → <code>{{ s.use_instead }}</code>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pdt {
|
||||||
|
padding: var(--fs-space-2) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-note {
|
||||||
|
background: var(--fs-surface-hover);
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-left: 3px solid var(--fs-warning);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
padding: var(--fs-space-3) var(--fs-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-note p {
|
||||||
|
margin: var(--fs-space-2) 0 0;
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
line-height: var(--fs-leading-body);
|
||||||
|
max-width: 70ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-muted,
|
||||||
|
.pdt-clean,
|
||||||
|
.pdt-summary {
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
margin: 0 0 var(--fs-space-3);
|
||||||
|
max-width: 70ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-clean {
|
||||||
|
color: var(--fs-status-done);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-summary {
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--fs-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-finding {
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-md);
|
||||||
|
padding: var(--fs-space-3);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-title {
|
||||||
|
display: block;
|
||||||
|
font-weight: var(--fs-weight-medium);
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
margin-bottom: var(--fs-space-2);
|
||||||
|
}
|
||||||
|
.pdt-title:hover { color: var(--fs-accent); }
|
||||||
|
|
||||||
|
.pdt-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--fs-space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.15rem 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-tag {
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--fs-tracking-tiny);
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-tag.unknown {
|
||||||
|
background: var(--fs-priority-high-bg);
|
||||||
|
color: var(--fs-priority-high);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-tag.local {
|
||||||
|
background: var(--fs-priority-medium-bg);
|
||||||
|
color: var(--fs-priority-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-tag.superseded {
|
||||||
|
background: var(--fs-surface-hover);
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--fs-space-2);
|
||||||
|
font-size: var(--fs-size-code);
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdt-swap {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -51,10 +51,10 @@ function onChange(e: Event) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.project-selector {
|
.project-selector {
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.4rem 0.5rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -62,6 +62,6 @@ function onChange(e: Event) {
|
|||||||
}
|
}
|
||||||
.project-selector:focus {
|
.project-selector:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -153,19 +153,19 @@ const calendarDayMax = computed(() =>
|
|||||||
}
|
}
|
||||||
.rec-label {
|
.rec-label {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
min-width: 2.5rem;
|
min-width: 2.5rem;
|
||||||
}
|
}
|
||||||
.rec-num-input {
|
.rec-num-input {
|
||||||
width: 4rem;
|
width: 4rem;
|
||||||
padding: 0.25rem 0.4rem;
|
padding: 0.25rem 0.4rem;
|
||||||
border: 1px solid var(--color-input-border, var(--color-border));
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.rec-num-input:focus { outline: none; border-color: var(--color-primary); }
|
.rec-num-input:focus { outline: none; border-color: var(--fs-accent); }
|
||||||
.rec-unit { min-width: 6rem; }
|
.rec-unit { min-width: 6rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ defineExpose({ focus: () => inputRef.value?.focus() });
|
|||||||
.search-input {
|
.search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.search-input::placeholder {
|
.search-input::placeholder {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ onMounted(async () => {
|
|||||||
<div class="share-dialog" role="dialog" :aria-label="`Share ${resourceTitle}`">
|
<div class="share-dialog" role="dialog" :aria-label="`Share ${resourceTitle}`">
|
||||||
<header class="share-header">
|
<header class="share-header">
|
||||||
<h2 class="share-title">Share "{{ resourceTitle }}"</h2>
|
<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>
|
</header>
|
||||||
|
|
||||||
<!-- Add share form -->
|
<!-- Add share form -->
|
||||||
@@ -184,7 +184,7 @@ onMounted(async () => {
|
|||||||
<option value="editor">Editor</option>
|
<option value="editor">Editor</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="admin">Admin</option>
|
||||||
</select>
|
</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>
|
||||||
<li v-if="!shares.length" class="shares-empty">Not shared with anyone yet</li>
|
<li v-if="!shares.length" class="shares-empty">Not shared with anyone yet</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -206,9 +206,9 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.share-dialog {
|
.share-dialog {
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--fs-radius-xl);
|
||||||
width: 480px;
|
width: 480px;
|
||||||
max-width: 95vw;
|
max-width: 95vw;
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
@@ -220,7 +220,7 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 1.25rem 1.5rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-title {
|
.share-title {
|
||||||
@@ -228,26 +228,9 @@ onMounted(async () => {
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
.share-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -257,18 +240,18 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.share-tab {
|
.share-tab {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 0.3rem 0.8rem;
|
padding: 0.3rem 0.8rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
.share-tab.active {
|
.share-tab.active {
|
||||||
background: var(--color-primary);
|
background: var(--fs-accent);
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
color: #fff;
|
color: var(--fs-text-on-action);
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-target-form {
|
.share-target-form {
|
||||||
@@ -286,23 +269,23 @@ onMounted(async () => {
|
|||||||
.share-input {
|
.share-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.45rem 0.7rem;
|
padding: 0.45rem 0.7rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
.share-input:focus { border-color: var(--color-primary); }
|
.share-input:focus { border-color: var(--fs-accent); }
|
||||||
|
|
||||||
.user-results {
|
.user-results {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -321,29 +304,29 @@ onMounted(async () => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
.user-result-item:hover { background: var(--color-bg-secondary); }
|
.user-result-item:hover { background: var(--fs-surface-raised); }
|
||||||
|
|
||||||
.user-result-name { font-weight: 600; font-size: 0.88rem; }
|
.user-result-name { font-weight: 600; font-size: 0.88rem; }
|
||||||
.user-result-email { color: var(--color-text-muted); font-size: 0.8rem; }
|
.user-result-email { color: var(--fs-text-tertiary); font-size: 0.8rem; }
|
||||||
|
|
||||||
.perm-select {
|
.perm-select {
|
||||||
padding: 0.45rem 0.5rem;
|
padding: 0.45rem 0.5rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-add-share {
|
.btn-add-share {
|
||||||
padding: 0.45rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
background: var(--gradient-cta);
|
background: var(--fs-gradient-cta);
|
||||||
color: #fff;
|
color: var(--fs-text-on-action);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: var(--fs-weight-medium);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -359,7 +342,7 @@ onMounted(async () => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +361,7 @@ onMounted(async () => {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
.share-target-icon { font-size: 1rem; flex-shrink: 0; }
|
.share-target-icon { font-size: 1rem; flex-shrink: 0; }
|
||||||
@@ -386,31 +369,12 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.perm-select-inline {
|
.perm-select-inline {
|
||||||
padding: 0.25rem 0.4rem;
|
padding: 0.25rem 0.4rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
cursor: pointer;
|
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>
|
</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(--fs-text-primary);
|
||||||
|
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(--fs-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(--fs-surface-hover); }
|
||||||
|
|
||||||
|
.srp-name {
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.srp-count {
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
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(--fs-text-tertiary);
|
||||||
|
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(--fs-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(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -38,20 +38,20 @@ const labels: Record<TaskStatus, string> = {
|
|||||||
letter-spacing: 0.025em;
|
letter-spacing: 0.025em;
|
||||||
}
|
}
|
||||||
.status-todo {
|
.status-todo {
|
||||||
background: color-mix(in srgb, var(--color-status-todo-bg) 78%, var(--color-status-todo) 22%);
|
background: color-mix(in srgb, var(--fs-status-todo-bg) 78%, var(--fs-status-todo) 22%);
|
||||||
color: color-mix(in srgb, var(--color-status-todo) 85%, #000 15%);
|
color: color-mix(in srgb, var(--fs-status-todo) 85%, #000 15%);
|
||||||
}
|
}
|
||||||
.status-in_progress {
|
.status-in_progress {
|
||||||
background: color-mix(in srgb, var(--color-status-in-progress-bg) 78%, var(--color-status-in-progress) 22%);
|
background: color-mix(in srgb, var(--fs-status-in-progress-bg) 78%, var(--fs-status-in-progress) 22%);
|
||||||
color: color-mix(in srgb, var(--color-status-in-progress) 85%, #000 15%);
|
color: color-mix(in srgb, var(--fs-status-in-progress) 85%, #000 15%);
|
||||||
}
|
}
|
||||||
.status-done {
|
.status-done {
|
||||||
background: color-mix(in srgb, var(--color-status-done-bg) 78%, var(--color-status-done) 22%);
|
background: color-mix(in srgb, var(--fs-status-done-bg) 78%, var(--fs-status-done) 22%);
|
||||||
color: color-mix(in srgb, var(--color-status-done) 85%, #000 15%);
|
color: color-mix(in srgb, var(--fs-status-done) 85%, #000 15%);
|
||||||
}
|
}
|
||||||
.status-cancelled {
|
.status-cancelled {
|
||||||
background: color-mix(in srgb, var(--color-bg-secondary) 78%, var(--color-text-muted) 22%);
|
background: color-mix(in srgb, var(--fs-surface-raised) 78%, var(--fs-text-tertiary) 22%);
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.clickable {
|
.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ defineExpose({ onKeyDown });
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
box-shadow: 0 4px 12px var(--color-shadow);
|
box-shadow: 0 4px 12px var(--color-shadow);
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -85,6 +85,6 @@ defineExpose({ onKeyDown });
|
|||||||
}
|
}
|
||||||
.ac-item:hover,
|
.ac-item:hover,
|
||||||
.ac-item.active {
|
.ac-item.active {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ async function confirmDelete() {
|
|||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="systems-toolbar">
|
<div class="systems-toolbar">
|
||||||
<button v-if="!showCreate" class="btn-add-system" @click="openCreate">
|
<button v-if="!showCreate" class="btn-ghost btn-inline btn-add-system" @click="openCreate">
|
||||||
+ System
|
+ System
|
||||||
</button>
|
</button>
|
||||||
<label v-if="archivedSystems.length" class="archived-toggle">
|
<label v-if="archivedSystems.length" class="archived-toggle">
|
||||||
@@ -190,10 +190,10 @@ async function confirmDelete() {
|
|||||||
aria-label="System description"
|
aria-label="System description"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="system-form-actions">
|
<div class="system-form-actions">
|
||||||
<button type="submit" class="btn-confirm" :disabled="!newName.trim() || creating">
|
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
|
||||||
{{ creating ? "Creating…" : "Create" }}
|
{{ creating ? "Creating…" : "Create" }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-cancel" @click="cancelCreate">Cancel</button>
|
<button type="button" class="btn-ghost btn-compact" @click="cancelCreate">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ async function confirmDelete() {
|
|||||||
<div v-else-if="!visibleSystems.length" class="systems-empty">
|
<div v-else-if="!visibleSystems.length" class="systems-empty">
|
||||||
<p class="empty-title">No systems yet</p>
|
<p class="empty-title">No systems yet</p>
|
||||||
<p class="empty-sub">Define a reusable subsystem or area to organize issues against.</p>
|
<p class="empty-sub">Define a reusable subsystem or area to organize issues against.</p>
|
||||||
<button v-if="!showCreate" class="btn-confirm" @click="openCreate">+ Create a system</button>
|
<button v-if="!showCreate" class="btn-primary btn-compact" @click="openCreate">+ Create a system</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- List -->
|
<!-- List -->
|
||||||
@@ -241,10 +241,10 @@ async function confirmDelete() {
|
|||||||
aria-label="System description"
|
aria-label="System description"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="system-form-actions">
|
<div class="system-form-actions">
|
||||||
<button type="submit" class="btn-confirm" :disabled="!editName.trim() || savingEdit">
|
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
|
||||||
{{ savingEdit ? "Saving…" : "Save" }}
|
{{ savingEdit ? "Saving…" : "Save" }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-cancel" @click="cancelEdit">Cancel</button>
|
<button type="button" class="btn-ghost btn-compact" @click="cancelEdit">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
@@ -253,7 +253,7 @@ async function confirmDelete() {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<span
|
<span
|
||||||
class="system-swatch"
|
class="system-swatch"
|
||||||
:style="{ background: system.color || 'var(--color-text-muted)' }"
|
:style="{ background: system.color || 'var(--fs-text-tertiary)' }"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
<div class="system-body">
|
<div class="system-body">
|
||||||
@@ -325,41 +325,41 @@ async function confirmDelete() {
|
|||||||
|
|
||||||
/* ── Open issues ──────────────────────────────────────────────── */
|
/* ── Open issues ──────────────────────────────────────────────── */
|
||||||
.open-issues { display: flex; flex-direction: column; gap: 0.35rem; }
|
.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); }
|
.open-issues-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--fs-text-tertiary); }
|
||||||
.issue-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.2rem; }
|
.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 { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; border-radius: var(--fs-radius-sm); text-decoration: none; color: var(--fs-text-primary); font-size: 0.85rem; }
|
||||||
.issue-link:hover { background: var(--color-bg-secondary); }
|
.issue-link:hover { background: var(--fs-surface-raised); }
|
||||||
.issue-mark { color: var(--color-text-muted); flex-shrink: 0; }
|
.issue-mark { color: var(--fs-text-tertiary); flex-shrink: 0; }
|
||||||
.issue-mark.imk-in_progress { color: var(--color-primary); }
|
.issue-mark.imk-in_progress { color: var(--fs-accent); }
|
||||||
.issue-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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-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; }
|
.issue-sys-chip { font-size: 0.66rem; color: var(--fs-text-secondary); background: var(--fs-surface-raised); border-radius: 999px; padding: 0.05rem 0.4rem; }
|
||||||
|
|
||||||
/* ── Toolbar ──────────────────────────────────────────────────── */
|
/* ── Toolbar ──────────────────────────────────────────────────── */
|
||||||
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
.systems-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; }
|
||||||
.btn-add-system {
|
.btn-add-system {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px dashed var(--color-border);
|
border: 1px dashed var(--fs-border-color);
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
padding: 0.28rem 0.65rem;
|
padding: 0.28rem 0.65rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.btn-add-system:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
.btn-add-system:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||||
.btn-add-system:focus-visible { outline: none; border-color: var(--color-primary); color: var(--color-primary); }
|
.btn-add-system:focus-visible { outline: none; border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||||
|
|
||||||
.archived-toggle {
|
.archived-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.archived-checkbox { accent-color: var(--color-primary); cursor: pointer; }
|
.archived-checkbox { accent-color: var(--fs-accent); cursor: pointer; }
|
||||||
|
|
||||||
/* ── Create / edit form ───────────────────────────────────────── */
|
/* ── Create / edit form ───────────────────────────────────────── */
|
||||||
.system-form {
|
.system-form {
|
||||||
@@ -367,71 +367,61 @@ async function confirmDelete() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--fs-radius-lg);
|
||||||
}
|
}
|
||||||
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
|
.system-form--inline { padding: 0; background: none; border: none; flex: 1; }
|
||||||
.system-input, .system-textarea {
|
.system-input, .system-textarea {
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--color-primary); }
|
.system-input:focus, .system-textarea:focus { outline: none; border-color: var(--fs-accent); }
|
||||||
.system-textarea { resize: vertical; }
|
.system-textarea { resize: vertical; }
|
||||||
|
|
||||||
.system-form-actions { display: flex; gap: 0.4rem; }
|
.system-form-actions { display: flex; gap: 0.4rem; }
|
||||||
.btn-confirm {
|
|
||||||
padding: 0.35rem 0.8rem;
|
|
||||||
background: var(--color-action-primary);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.btn-confirm:hover:not(:disabled) { background: var(--color-action-primary-hover); }
|
|
||||||
.btn-confirm:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
|
|
||||||
.btn-confirm:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.btn-cancel {
|
|
||||||
padding: 0.35rem 0.8rem;
|
|
||||||
background: var(--color-action-secondary);
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.btn-cancel:hover { background: var(--color-action-secondary-hover); }
|
|
||||||
.btn-cancel:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
|
|
||||||
|
|
||||||
/* ── List ─────────────────────────────────────────────────────── */
|
/* RESTORED (#2444). Both lost their base rule to a CSS sweep; only the
|
||||||
.systems-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.4rem; }
|
`--archived` modifier and the `:hover .system-actions` reveal survived.
|
||||||
|
The card WAS a flex row and every child still says so — `.system-swatch`
|
||||||
|
and `.system-actions` are `flex-shrink: 0`, `.system-body` is `flex: 1`,
|
||||||
|
and `.system-form--inline` is `flex: 1`. `align-items: flex-start` is why
|
||||||
|
the swatch carries `margin-top: 0.3rem`: it is nudged onto the first line
|
||||||
|
of text rather than centred against the whole card.
|
||||||
|
|
||||||
|
The list had no rule at all, so it rendered with browser bullets and
|
||||||
|
indent — invisible to the dangling-style check, which can only see a class
|
||||||
|
that is PARTLY styled. A class with no rules anywhere looks exactly like a
|
||||||
|
semantic-only hook.
|
||||||
|
|
||||||
|
Surface values match `.system-form` above, which is the same card shape in
|
||||||
|
this file and the reason they can be recovered rather than guessed. */
|
||||||
|
.systems-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.system-card {
|
.system-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.65rem;
|
gap: 0.6rem;
|
||||||
padding: 0.65rem 0.85rem;
|
padding: 0.6rem 0.75rem;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--fs-radius-lg);
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
|
||||||
transition: border-color 0.12s, box-shadow 0.15s;
|
|
||||||
}
|
|
||||||
.system-card:hover {
|
|
||||||
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
|
|
||||||
box-shadow: 0 3px 10px rgba(0,0,0,0.07);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-card--archived { opacity: 0.6; }
|
.system-card--archived { opacity: 0.6; }
|
||||||
|
|
||||||
.system-swatch {
|
.system-swatch {
|
||||||
@@ -443,13 +433,13 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
.system-body { flex: 1; min-width: 0; }
|
.system-body { flex: 1; min-width: 0; }
|
||||||
.system-name-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
.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; }
|
.system-name { font-weight: 500; color: var(--fs-text-primary); word-break: break-word; }
|
||||||
.issue-badge {
|
.issue-badge {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
border: 1px solid color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.05rem 0.45rem;
|
padding: 0.05rem 0.45rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -459,15 +449,15 @@ async function confirmDelete() {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.05rem 0.45rem;
|
padding: 0.05rem 0.45rem;
|
||||||
}
|
}
|
||||||
.system-description {
|
.system-description {
|
||||||
margin: 0.25rem 0 0;
|
margin: 0.25rem 0 0;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -482,15 +472,15 @@ async function confirmDelete() {
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
transition: background 0.12s, color 0.12s;
|
transition: background 0.12s, color 0.12s;
|
||||||
}
|
}
|
||||||
.action-btn:hover { background: var(--color-bg-secondary); color: var(--color-text); }
|
.action-btn:hover { background: var(--fs-surface-raised); color: var(--fs-text-primary); }
|
||||||
.action-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; opacity: 1; }
|
.action-btn:focus-visible { outline: 2px solid var(--fs-accent); outline-offset: 1px; opacity: 1; }
|
||||||
.action-delete:hover { color: var(--color-danger, #e74c3c); }
|
.action-delete:hover { color: var(--fs-error); }
|
||||||
|
|
||||||
/* ── Empty ────────────────────────────────────────────────────── */
|
/* ── Empty ────────────────────────────────────────────────────── */
|
||||||
.systems-empty {
|
.systems-empty {
|
||||||
@@ -500,61 +490,29 @@ async function confirmDelete() {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px dashed var(--color-border);
|
border: 1px dashed var(--fs-border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--fs-radius-lg);
|
||||||
}
|
}
|
||||||
.empty-title { margin: 0; font-weight: 500; color: var(--color-text); }
|
.empty-title { margin: 0; font-weight: 500; color: var(--fs-text-primary); }
|
||||||
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--color-text-muted); max-width: 32ch; }
|
.empty-sub { margin: 0 0 0.5rem; font-size: 0.82rem; color: var(--fs-text-tertiary); max-width: 32ch; }
|
||||||
|
|
||||||
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
|
.error-msg { color: var(--fs-error); font-size: 0.9rem; }
|
||||||
|
|
||||||
/* ── Skeleton ─────────────────────────────────────────────────── */
|
/* ── Skeleton ─────────────────────────────────────────────────── */
|
||||||
@keyframes skel-shine { to { background-position: 200% center; } }
|
@keyframes skel-shine { to { background-position: 200% center; } }
|
||||||
.systems-skeleton { display: flex; flex-direction: column; gap: 0.4rem; }
|
.systems-skeleton { display: flex; flex-direction: column; gap: 0.4rem; }
|
||||||
.skel-row {
|
.skel-row {
|
||||||
height: 3rem;
|
height: 3rem;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--fs-radius-lg);
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
var(--color-bg-secondary) 25%,
|
var(--fs-surface-raised) 25%,
|
||||||
color-mix(in srgb, var(--color-text-muted) 16%, var(--color-bg-secondary)) 50%,
|
color-mix(in srgb, var(--fs-text-tertiary) 16%, var(--fs-surface-raised)) 50%,
|
||||||
var(--color-bg-secondary) 75%
|
var(--fs-surface-raised) 75%
|
||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: skel-shine 1.5s ease infinite;
|
animation: skel-shine 1.5s ease infinite;
|
||||||
}
|
}
|
||||||
.skel-row--short { width: 65%; }
|
.skel-row--short { width: 65%; }
|
||||||
|
|
||||||
/* ── Modal ────────────────────────────────────────────────────── */
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed; inset: 0;
|
|
||||||
background: var(--color-overlay, rgba(0,0,0,0.45));
|
|
||||||
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: #fff; }
|
|
||||||
.modal-btn-danger:hover { background: var(--color-action-destructive-hover); border-color: var(--color-action-destructive-hover); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function scrollTo(id: string) {
|
|||||||
.toc-title {
|
.toc-title {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
@@ -73,11 +73,11 @@ function scrollTo(id: string) {
|
|||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
.toc-link {
|
.toc-link {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.toc-link:hover {
|
.toc-link:hover {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -154,9 +154,9 @@ function focusInput() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
padding: 0.35rem 0.6rem;
|
padding: 0.35rem 0.6rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
cursor: text;
|
cursor: text;
|
||||||
min-height: 2.25rem;
|
min-height: 2.25rem;
|
||||||
}
|
}
|
||||||
@@ -166,9 +166,9 @@ function focusInput() {
|
|||||||
gap: 0.2rem;
|
gap: 0.2rem;
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||||
border: 1px solid var(--color-primary);
|
border: 1px solid var(--fs-accent);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -195,7 +195,7 @@ function focusInput() {
|
|||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -205,9 +205,9 @@ function focusInput() {
|
|||||||
left: 0;
|
left: 0;
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -218,11 +218,11 @@ function focusInput() {
|
|||||||
padding: 0.35rem 0.75rem;
|
padding: 0.35rem 0.75rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.tag-autocomplete-item:hover,
|
.tag-autocomplete-item:hover,
|
||||||
.tag-autocomplete-item.selected {
|
.tag-autocomplete-item.selected {
|
||||||
background: var(--color-bg-hover, color-mix(in srgb, var(--color-primary) 8%, transparent));
|
background: var(--fs-surface-hover);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ defineEmits<{
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
background: var(--color-tag-bg);
|
background: var(--fs-accent-soft);
|
||||||
color: var(--color-tag-text);
|
color: var(--fs-accent);
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
@@ -39,13 +39,13 @@ defineEmits<{
|
|||||||
transition: color 0.15s, background 0.15s;
|
transition: color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
.tag-pill:hover {
|
.tag-pill:hover {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
background: var(--color-primary-tint);
|
background: var(--fs-accent-soft);
|
||||||
}
|
}
|
||||||
.dismiss {
|
.dismiss {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--color-tag-text);
|
color: var(--fs-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|||||||
@@ -108,15 +108,15 @@ function isOverdue(): boolean {
|
|||||||
.task-card {
|
.task-card {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--fs-radius-lg);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
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(--fs-accent) 6%, transparent);
|
||||||
transition: box-shadow 0.2s, transform 0.18s ease;
|
transition: box-shadow 0.2s, transform 0.18s ease;
|
||||||
}
|
}
|
||||||
.task-card:hover {
|
.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(--fs-accent) 14.0%, transparent);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,19 +144,19 @@ function isOverdue(): boolean {
|
|||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
.dot-todo {
|
.dot-todo {
|
||||||
background: var(--color-status-todo, #94a3b8);
|
background: var(--fs-status-todo);
|
||||||
border: 2px solid var(--color-status-todo, #94a3b8);
|
border: 2px solid var(--fs-status-todo);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 2px solid var(--color-text-muted);
|
border: 2px solid var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.dot-in-progress {
|
.dot-in-progress {
|
||||||
background: var(--color-status-in-progress, #3b82f6);
|
background: var(--fs-status-in-progress);
|
||||||
}
|
}
|
||||||
.dot-done {
|
.dot-done {
|
||||||
background: var(--color-status-done, #22c55e);
|
background: var(--fs-status-done);
|
||||||
}
|
}
|
||||||
.dot-cancelled {
|
.dot-cancelled {
|
||||||
background: var(--color-status-cancelled, #6b7280);
|
background: var(--fs-status-cancelled);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-title-compact {
|
.task-title-compact {
|
||||||
@@ -170,10 +170,10 @@ function isOverdue(): boolean {
|
|||||||
}
|
}
|
||||||
.project-crumb {
|
.project-crumb {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
padding: 0.1rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -185,12 +185,12 @@ function isOverdue(): boolean {
|
|||||||
}
|
}
|
||||||
.due-compact {
|
.due-compact {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.due-compact.overdue {
|
.due-compact.overdue {
|
||||||
color: var(--color-danger, #e74c3c);
|
color: var(--fs-error);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
/* Full layout */
|
/* Full layout */
|
||||||
@@ -211,7 +211,7 @@ function isOverdue(): boolean {
|
|||||||
}
|
}
|
||||||
.task-preview {
|
.task-preview {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
max-height: 7.5em;
|
max-height: 7.5em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -224,15 +224,15 @@ function isOverdue(): boolean {
|
|||||||
}
|
}
|
||||||
.due-date {
|
.due-date {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
}
|
}
|
||||||
.due-date.overdue {
|
.due-date.overdue {
|
||||||
color: var(--color-overdue);
|
color: var(--fs-overdue);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.timestamp {
|
.timestamp {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, onMounted } from "vue";
|
|||||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
import type { TaskLog } from "@/types/task";
|
import type { TaskLog } from "@/types/task";
|
||||||
|
import { fmtStamp } from "@/utils/dateFormat";
|
||||||
|
|
||||||
const props = defineProps<{ taskId: number }>();
|
const props = defineProps<{ taskId: number }>();
|
||||||
|
|
||||||
@@ -15,13 +16,6 @@ const editingId = ref<number | null>(null);
|
|||||||
const editContent = ref("");
|
const editContent = ref("");
|
||||||
const editDuration = ref("");
|
const editDuration = ref("");
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
const d = new Date(iso);
|
|
||||||
const datePart = d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
|
||||||
const timePart = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
||||||
return `${datePart}, ${timePart}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(minutes: number): string {
|
function formatDuration(minutes: number): string {
|
||||||
if (minutes < 60) return `${minutes} min`;
|
if (minutes < 60) return `${minutes} min`;
|
||||||
const h = Math.floor(minutes / 60);
|
const h = Math.floor(minutes / 60);
|
||||||
@@ -122,19 +116,19 @@ onMounted(loadLogs);
|
|||||||
class="log-duration-input"
|
class="log-duration-input"
|
||||||
placeholder="min"
|
placeholder="min"
|
||||||
/>
|
/>
|
||||||
<button class="btn-log-save" @click="saveEdit(log)">Save</button>
|
<button class="btn-primary btn-compact" @click="saveEdit(log)">Save</button>
|
||||||
<button class="btn-log-cancel" @click="cancelEdit">Cancel</button>
|
<button class="btn-ghost btn-compact" @click="cancelEdit">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="log-entry-meta">
|
<div class="log-entry-meta">
|
||||||
<span class="log-date">{{ formatDate(log.created_at) }}</span>
|
<span class="log-date">{{ fmtStamp(log.created_at) }}</span>
|
||||||
<span v-if="log.duration_minutes" class="log-duration-badge">
|
<span v-if="log.duration_minutes" class="log-duration-badge">
|
||||||
{{ formatDuration(log.duration_minutes) }}
|
{{ formatDuration(log.duration_minutes) }}
|
||||||
</span>
|
</span>
|
||||||
<div class="log-entry-actions">
|
<div class="log-entry-actions">
|
||||||
<button class="btn-log-edit" @click="startEdit(log)" title="Edit">Edit</button>
|
<button class="btn-text" @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" aria-label="Delete log entry" @click="deleteLog(log)">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="log-content prose" v-html="renderMarkdown(log.content)"></div>
|
<div class="log-content prose" v-html="renderMarkdown(log.content)"></div>
|
||||||
@@ -162,7 +156,7 @@ onMounted(loadLogs);
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
class="btn-log-submit"
|
class="btn-primary btn-compact"
|
||||||
@click="submitLog"
|
@click="submitLog"
|
||||||
:disabled="!newContent.trim() || submitting"
|
:disabled="!newContent.trim() || submitting"
|
||||||
>
|
>
|
||||||
@@ -175,9 +169,9 @@ onMounted(loadLogs);
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.log-section {
|
.log-section {
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.6rem 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -187,7 +181,7 @@ onMounted(loadLogs);
|
|||||||
.log-header {
|
.log-header {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
margin-bottom: 0.15rem;
|
margin-bottom: 0.15rem;
|
||||||
@@ -195,11 +189,11 @@ onMounted(loadLogs);
|
|||||||
|
|
||||||
.log-empty {
|
.log-empty {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entry {
|
.log-entry {
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--fs-border-color);
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,12 +206,12 @@ onMounted(loadLogs);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-date {
|
.log-date {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-duration-badge {
|
.log-duration-badge {
|
||||||
background: var(--color-primary);
|
background: var(--fs-accent);
|
||||||
color: #fff;
|
color: var(--fs-text-on-action);
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem 0.5rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -230,42 +224,15 @@ onMounted(loadLogs);
|
|||||||
gap: 0.25rem;
|
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 {
|
.log-textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.4rem 0.5rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -274,7 +241,7 @@ onMounted(loadLogs);
|
|||||||
|
|
||||||
.log-textarea:focus {
|
.log-textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-add-controls,
|
.log-add-controls,
|
||||||
@@ -286,7 +253,7 @@ onMounted(loadLogs);
|
|||||||
|
|
||||||
.log-duration-label {
|
.log-duration-label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
@@ -295,44 +262,18 @@ onMounted(loadLogs);
|
|||||||
.log-duration-input {
|
.log-duration-input {
|
||||||
width: 5rem;
|
width: 5rem;
|
||||||
padding: 0.3rem 0.4rem;
|
padding: 0.3rem 0.4rem;
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-duration-input:focus {
|
.log-duration-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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>
|
</style>
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ defineExpose({ editor });
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.editor-error {
|
.editor-error {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
color: var(--color-danger);
|
color: var(--fs-error);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const toastStore = useToastStore();
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
color: #fff;
|
color: var(--fs-text-on-action);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
box-shadow: 0 2px 8px var(--color-shadow);
|
box-shadow: 0 2px 8px var(--color-shadow);
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
@@ -54,16 +54,16 @@ const toastStore = useToastStore();
|
|||||||
padding: 0 0.15rem;
|
padding: 0 0.15rem;
|
||||||
}
|
}
|
||||||
.toast-close:hover {
|
.toast-close:hover {
|
||||||
color: #fff;
|
color: var(--fs-text-on-action);
|
||||||
}
|
}
|
||||||
.toast--success {
|
.toast--success {
|
||||||
background: var(--color-toast-success);
|
background: var(--fs-success);
|
||||||
}
|
}
|
||||||
.toast--error {
|
.toast--error {
|
||||||
background: var(--color-toast-error);
|
background: var(--fs-error);
|
||||||
}
|
}
|
||||||
.toast--warning {
|
.toast--warning {
|
||||||
background: var(--color-warning);
|
background: var(--fs-warning);
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
}
|
}
|
||||||
.toast--warning .toast-close {
|
.toast--warning .toast-close {
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* A design system's tokens, drawn rather than listed (#2431).
|
||||||
|
*
|
||||||
|
* WHAT MAKES THIS WORK FOR A SYSTEM YOU AREN'T RUNNING
|
||||||
|
* Every value is resolved on an offscreen probe carrying only this system's
|
||||||
|
* declarations (`resolveDeclared`), never read from the page. So a token like
|
||||||
|
* `color-mix(in srgb, var(--accent) 15%, transparent)` shows THIS system's
|
||||||
|
* accent, not the accent of the app you happen to be looking at. Previewing
|
||||||
|
* another project's palette from here is the point; a preview that quietly
|
||||||
|
* borrows the host app's values would be worse than no preview, because it
|
||||||
|
* would look right.
|
||||||
|
*
|
||||||
|
* SPECIMENS ARE CHOSEN BY VALUE SHAPE, NEVER BY NAME
|
||||||
|
* A colour is drawn as a swatch, a length as a rule of that length, a font
|
||||||
|
* stack as text set in it. Nothing here matches `--fs-space-*` or any other
|
||||||
|
* naming convention, because the convention is the install's (rule #115) — a
|
||||||
|
* system that calls its spacing `--gap-N` gets the same treatment.
|
||||||
|
*
|
||||||
|
* A token with no value for the chosen mode is shown as undecided rather than
|
||||||
|
* skipped. A named role awaiting a decision is information; a gap in a grid
|
||||||
|
* is not.
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
|
||||||
|
import type { ResolvedToken } from "@/api/designSystems";
|
||||||
|
import { BASE_MODE, modesPresent, resolveDeclared, valueForMode } from "@/utils/designValues";
|
||||||
|
|
||||||
|
const props = defineProps<{ tokens: ResolvedToken[] }>();
|
||||||
|
|
||||||
|
const modes = computed(() => modesPresent(props.tokens));
|
||||||
|
const mode = ref(BASE_MODE);
|
||||||
|
|
||||||
|
/** Values as the browser would compute them, for the chosen mode. */
|
||||||
|
const rendered = ref<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
function recompute() {
|
||||||
|
const declared = new Map<string, string>();
|
||||||
|
for (const token of props.tokens) {
|
||||||
|
const value = valueForMode(token.value_by_mode, mode.value);
|
||||||
|
if (value) declared.set(token.name, value);
|
||||||
|
}
|
||||||
|
rendered.value = resolveDeclared(declared);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => props.tokens, mode],
|
||||||
|
() => {
|
||||||
|
// Keep the selection only while it still exists — switching systems can
|
||||||
|
// drop a mode, and a stale one would silently render as base.
|
||||||
|
if (!modes.value.includes(mode.value)) mode.value = modes.value[0] ?? BASE_MODE;
|
||||||
|
recompute();
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
type Shape = "colour" | "surface" | "length" | "font" | "plain";
|
||||||
|
|
||||||
|
const COLOUR = /^(#|rgba?\(|hsla?\(|color-mix\(|light-dark\()/;
|
||||||
|
const LENGTH = /^-?\d*\.?\d+(px|rem|em|ch|vh|vw)$/;
|
||||||
|
const GRADIENT = /gradient\(/;
|
||||||
|
/** Two or more space-separated parts ending in a colour — i.e. a shadow. */
|
||||||
|
const SHADOW = /^[^,]*\d\s+.*(#|rgba?\(|color-mix\()/;
|
||||||
|
/** A stack of family names: commas, no functions, no digits. */
|
||||||
|
const FONT_STACK = /^[^(){}\d]+,[^(){}\d]+$/;
|
||||||
|
|
||||||
|
function shapeOf(value: string): Shape {
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return "plain";
|
||||||
|
if (COLOUR.test(v)) return "colour";
|
||||||
|
if (GRADIENT.test(v) || SHADOW.test(v)) return "surface";
|
||||||
|
if (LENGTH.test(v)) return "length";
|
||||||
|
if (FONT_STACK.test(v)) return "font";
|
||||||
|
return "plain";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Specimen {
|
||||||
|
name: string;
|
||||||
|
declared: string;
|
||||||
|
rendered: string;
|
||||||
|
shape: Shape;
|
||||||
|
purpose: string | null;
|
||||||
|
/** True when `var()` substitution changed the value — worth showing on hover. */
|
||||||
|
substituted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = computed(() => {
|
||||||
|
const out = new Map<string, Specimen[]>();
|
||||||
|
for (const token of props.tokens) {
|
||||||
|
const declared = valueForMode(token.value_by_mode, mode.value);
|
||||||
|
const value = rendered.value.get(token.name) ?? "";
|
||||||
|
const bucket = out.get(token.group_name ?? "ungrouped") ?? [];
|
||||||
|
bucket.push({
|
||||||
|
name: token.name,
|
||||||
|
declared,
|
||||||
|
rendered: value,
|
||||||
|
shape: shapeOf(value),
|
||||||
|
purpose: token.purpose,
|
||||||
|
substituted: Boolean(declared) && value !== declared,
|
||||||
|
});
|
||||||
|
out.set(token.group_name ?? "ungrouped", bucket);
|
||||||
|
}
|
||||||
|
return [...out.entries()];
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lengths are drawn to scale up to a ceiling, so a 40px heading and a 4px gap
|
||||||
|
* are visibly different — but a stray `100vw` can't stretch the row.
|
||||||
|
*/
|
||||||
|
function ruleWidth(value: string): string {
|
||||||
|
return `min(${value}, 12rem)`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tp">
|
||||||
|
<div v-if="modes.length > 1" class="tp-modes">
|
||||||
|
<button
|
||||||
|
v-for="m in modes"
|
||||||
|
:key="m"
|
||||||
|
class="tp-mode"
|
||||||
|
:class="{ active: m === mode }"
|
||||||
|
@click="mode = m"
|
||||||
|
>{{ m }}</button>
|
||||||
|
<span class="tp-modes-note">
|
||||||
|
The system's own modes — independent of the theme this app is in.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="[group, specimens] in groups" :key="group" class="tp-group">
|
||||||
|
<h3 class="tp-group-heading">{{ group }}</h3>
|
||||||
|
<ul class="tp-grid">
|
||||||
|
<li v-for="s in specimens" :key="s.name" class="tp-item">
|
||||||
|
<div
|
||||||
|
class="tp-specimen"
|
||||||
|
:class="`is-${s.shape}`"
|
||||||
|
:title="s.substituted ? `${s.declared} → ${s.rendered}` : s.declared"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="s.shape === 'colour'"
|
||||||
|
class="tp-swatch"
|
||||||
|
:style="{ '--tp-fill': s.rendered }"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else-if="s.shape === 'surface'"
|
||||||
|
class="tp-surface"
|
||||||
|
:style="s.rendered.includes('gradient(')
|
||||||
|
? { background: s.rendered }
|
||||||
|
: { boxShadow: s.rendered }"
|
||||||
|
/>
|
||||||
|
<span v-else-if="s.shape === 'length'" class="tp-rule-wrap">
|
||||||
|
<span class="tp-rule" :style="{ width: ruleWidth(s.rendered) }" />
|
||||||
|
<span class="tp-rule-label">{{ s.rendered }}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="s.shape === 'font'"
|
||||||
|
class="tp-font"
|
||||||
|
:style="{ fontFamily: s.rendered }"
|
||||||
|
>Ag</span>
|
||||||
|
<span v-else-if="!s.declared" class="tp-undecided">to be decided</span>
|
||||||
|
<span v-else class="tp-plain">{{ s.rendered }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<code class="tp-name">{{ s.name }}</code>
|
||||||
|
<span class="tp-value" :title="s.declared">{{ s.declared || "—" }}</span>
|
||||||
|
<span v-if="s.purpose" class="tp-purpose" :title="s.purpose">{{ s.purpose }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tp-modes {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--fs-space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: var(--fs-space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-mode {
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tp-mode:hover { color: var(--fs-text-primary); }
|
||||||
|
.tp-mode.active {
|
||||||
|
color: var(--fs-accent);
|
||||||
|
border-color: var(--fs-accent);
|
||||||
|
background: var(--fs-accent-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-modes-note {
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-group { margin-bottom: var(--fs-space-6); }
|
||||||
|
|
||||||
|
.tp-group-heading {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: var(--fs-tracking-tiny);
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
margin: 0 0 var(--fs-space-3);
|
||||||
|
padding-bottom: var(--fs-space-2);
|
||||||
|
border-bottom: var(--fs-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-grid {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
|
||||||
|
gap: var(--fs-space-4) var(--fs-space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-item {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A fixed-height stage so a 40px rule and a 2px one still line up in a grid.
|
||||||
|
*
|
||||||
|
* The stage itself is plain. An earlier version put the checkerboard here, so
|
||||||
|
* every specimen — including opaque colours and plain text — sat inside a
|
||||||
|
* frame of checks, and the pattern read as the loudest thing on the page. The
|
||||||
|
* checks belong to the ONE case that needs them: a colour that might be
|
||||||
|
* translucent. */
|
||||||
|
.tp-specimen {
|
||||||
|
height: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: var(--fs-radius-sm);
|
||||||
|
padding: var(--fs-space-1);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text-bearing specimens get no box at all — a border around a value is a
|
||||||
|
frame around nothing, which is most of what made the grid feel busy. */
|
||||||
|
.tp-specimen.is-plain,
|
||||||
|
.tp-specimen.is-length,
|
||||||
|
.tp-specimen.is-font {
|
||||||
|
background: none;
|
||||||
|
padding: 0 var(--fs-space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checks UNDER the colour, not around it: an opaque value hides them
|
||||||
|
completely, and a 15% tint shows exactly as much of them as it should.
|
||||||
|
Layering the fill as a gradient is what lets one element do both. */
|
||||||
|
.tp-swatch {
|
||||||
|
/* Declared here, overridden inline per swatch. Two reasons it is a real
|
||||||
|
default rather than a formality: a token that resolves to nothing renders
|
||||||
|
as bare checks instead of an invalid gradient, and a custom property that
|
||||||
|
exists ONLY as an inline style is invisible to the CI token check — which
|
||||||
|
reads it as an unresolvable reference, correctly, since nothing in any
|
||||||
|
stylesheet declares it. */
|
||||||
|
--tp-fill: transparent;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: calc(var(--fs-radius-sm) - 2px);
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--tp-fill), var(--tp-fill)),
|
||||||
|
repeating-conic-gradient(
|
||||||
|
var(--fs-border-color) 0% 25%,
|
||||||
|
var(--fs-surface-raised) 0% 50%
|
||||||
|
);
|
||||||
|
background-size: auto, 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-surface {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: calc(var(--fs-radius-sm) - 2px);
|
||||||
|
background: var(--fs-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-rule-wrap {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--fs-space-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-rule {
|
||||||
|
height: 0.4rem;
|
||||||
|
min-width: 1px;
|
||||||
|
flex: none;
|
||||||
|
background: var(--fs-accent);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-rule-label {
|
||||||
|
font-family: var(--fs-font-mono);
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-font {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-plain {
|
||||||
|
font-family: var(--fs-font-mono);
|
||||||
|
font-size: var(--fs-size-code);
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-undecided {
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One line each, with the full text on hover.
|
||||||
|
*
|
||||||
|
* These wrapped freely at first, so a card was two lines tall or five depending
|
||||||
|
* on how long its `color-mix()` happened to be, and the grid lost any rhythm —
|
||||||
|
* which is most of what "messy" was. A derived value is not something anyone
|
||||||
|
* reads character by character in a gallery; it is something you check the
|
||||||
|
* shape of and open if it matters. */
|
||||||
|
.tp-name,
|
||||||
|
.tp-value,
|
||||||
|
.tp-purpose {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-name {
|
||||||
|
font-size: var(--fs-size-body-sm);
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-value {
|
||||||
|
font-family: var(--fs-font-mono);
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tp-purpose {
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -164,7 +164,7 @@ function restore() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.vh-section {
|
.vh-section {
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vh-header {
|
.vh-header {
|
||||||
@@ -179,19 +179,19 @@ function restore() {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.vh-header:hover { background: var(--color-bg-secondary); }
|
.vh-header:hover { background: var(--fs-surface-raised); }
|
||||||
|
|
||||||
.vh-title {
|
.vh-title {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vh-chevron {
|
.vh-chevron {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vh-body {
|
.vh-body {
|
||||||
@@ -201,20 +201,20 @@ function restore() {
|
|||||||
.vh-empty {
|
.vh-empty {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vh-item {
|
.vh-item {
|
||||||
padding: 0.35rem 0.75rem;
|
padding: 0.35rem 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
border-left: 2px solid transparent;
|
border-left: 2px solid transparent;
|
||||||
}
|
}
|
||||||
.vh-item:hover {
|
.vh-item:hover {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
border-left-color: var(--color-primary);
|
border-left-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.vh-diff-actions {
|
.vh-diff-actions {
|
||||||
@@ -225,23 +225,23 @@ function restore() {
|
|||||||
|
|
||||||
.vh-btn-back {
|
.vh-btn-back {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
padding: 0.25rem 0.6rem;
|
padding: 0.25rem 0.6rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.vh-btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
.vh-btn-back:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||||
|
|
||||||
.vh-btn-restore {
|
.vh-btn-restore {
|
||||||
background: var(--color-primary);
|
background: var(--fs-action-primary);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
padding: 0.25rem 0.6rem;
|
padding: 0.25rem 0.6rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: #fff;
|
color: var(--fs-text-on-action);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: inherit;
|
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>
|
|
||||||
@@ -50,11 +50,11 @@ const label = computed(() => {
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.word-count:hover { color: var(--color-text); }
|
.word-count:hover { color: var(--fs-text-primary); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import TagInput from "@/components/TagInput.vue";
|
|||||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||||
import WordCount from "@/components/WordCount.vue";
|
import WordCount from "@/components/WordCount.vue";
|
||||||
import { Trash2, X } from "lucide-vue-next";
|
import { Trash2, X } from "lucide-vue-next";
|
||||||
|
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
projectId: number;
|
projectId: number;
|
||||||
@@ -252,20 +253,6 @@ async function confirmDelete(id: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
const d = new Date(iso);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - d.getTime();
|
|
||||||
const diffMin = Math.floor(diffMs / 60_000);
|
|
||||||
const diffHrs = Math.floor(diffMs / 3_600_000);
|
|
||||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
|
||||||
if (diffMin < 1) return "just now";
|
|
||||||
if (diffMin < 60) return `${diffMin}m ago`;
|
|
||||||
if (diffHrs < 24) return `${diffHrs}h ago`;
|
|
||||||
if (diffDays < 7) return `${diffDays}d ago`;
|
|
||||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(noteTitle, () => { dirty.value = true; });
|
watch(noteTitle, () => { dirty.value = true; });
|
||||||
watch(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); });
|
watch(noteBody, () => { dirty.value = true; if (editingId.value) scheduleLinkCheck(); });
|
||||||
watch(noteTags, () => { dirty.value = true; });
|
watch(noteTags, () => { dirty.value = true; });
|
||||||
@@ -314,14 +301,14 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
@keydown.enter="createNote"
|
@keydown.enter="createNote"
|
||||||
@keydown.escape="cancelNewNote"
|
@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 ? '…' : '+' }}
|
{{ creatingNote ? '…' : '+' }}
|
||||||
</button>
|
</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>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="rail-title">Notes</span>
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -333,7 +320,7 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
type="search"
|
type="search"
|
||||||
aria-label="Search notes"
|
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>
|
||||||
|
|
||||||
<div v-if="listLoading" class="rail-state">Loading…</div>
|
<div v-if="listLoading" class="rail-state">Loading…</div>
|
||||||
@@ -346,7 +333,7 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
>
|
>
|
||||||
<div class="note-row-main">
|
<div class="note-row-main">
|
||||||
<span class="note-row-title">{{ note.title || 'Untitled' }}</span>
|
<span class="note-row-title">{{ note.title || 'Untitled' }}</span>
|
||||||
<span class="note-row-age">{{ formatDate(note.updated_at) }}</span>
|
<span class="note-row-age">{{ relativeTimeOrDate(note.updated_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="note.tags?.length" class="note-row-tags">
|
<div v-if="note.tags?.length" class="note-row-tags">
|
||||||
<span
|
<span
|
||||||
@@ -358,12 +345,12 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
</div>
|
</div>
|
||||||
<div class="note-row-actions" @click.stop>
|
<div class="note-row-actions" @click.stop>
|
||||||
<template v-if="deletingId === note.id">
|
<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?' }}
|
{{ pendingDelete === note.id ? '…' : 'Delete?' }}
|
||||||
</button>
|
</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>
|
</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" />
|
<Trash2 :size="16" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -382,7 +369,7 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
<WordCount :body="noteBody" />
|
<WordCount :body="noteBody" />
|
||||||
<span v-if="dirty && !saving" class="unsaved">Unsaved</span>
|
<span v-if="dirty && !saving" class="unsaved">Unsaved</span>
|
||||||
<span v-if="saving" class="saving-txt">Saving…</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -400,7 +387,7 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
<div class="tag-row">
|
<div class="tag-row">
|
||||||
<TagInput v-model="noteTags" :fetchTags="(q: string) => notesStore.fetchAllTags(q)" />
|
<TagInput v-model="noteTags" :fetchTags="(q: string) => notesStore.fetchAllTags(q)" />
|
||||||
<button
|
<button
|
||||||
class="btn-suggest-tags"
|
class="btn-ghost btn-compact"
|
||||||
:disabled="tagSuggestions.suggestingTags.value"
|
:disabled="tagSuggestions.suggestingTags.value"
|
||||||
title="Auto-suggest tags from title and body"
|
title="Auto-suggest tags from title and body"
|
||||||
@click="tagSuggestions.fetchTagSuggestions()"
|
@click="tagSuggestions.fetchTagSuggestions()"
|
||||||
@@ -417,7 +404,7 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
:class="['btn-tag-suggestion', { applied: tagSuggestions.appliedTags.value.has(tag) }]"
|
:class="['btn-tag-suggestion', { applied: tagSuggestions.appliedTags.value.has(tag) }]"
|
||||||
@click="tagSuggestions.applyTagSuggestion(tag)"
|
@click="tagSuggestions.applyTagSuggestion(tag)"
|
||||||
>#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}</button>
|
>#{{ 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>
|
||||||
|
|
||||||
<div class="toolbar-row">
|
<div class="toolbar-row">
|
||||||
@@ -429,8 +416,8 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
<span v-for="s in linkSuggestions" :key="s.note_id" class="link-suggest-chip" :title="`Appears ${s.count}× unlinked`">
|
<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>
|
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
|
||||||
</span>
|
</span>
|
||||||
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
|
<button class="btn-ghost btn-inline" @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-text" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"><X :size="16" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
|
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
|
||||||
@@ -452,8 +439,8 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-surface);
|
background: var(--fs-surface-hover);
|
||||||
border-left: 1px solid var(--color-border);
|
border-left: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Left rail ── */
|
/* ── Left rail ── */
|
||||||
@@ -463,7 +450,7 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-bg-card, var(--color-bg-secondary));
|
background: var(--fs-surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-header {
|
.rail-header {
|
||||||
@@ -471,12 +458,12 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
padding: 0.5rem 0.6rem;
|
padding: 0.5rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rail-title {
|
.rail-title {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -484,55 +471,26 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
flex: 1;
|
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 {
|
.rail-search-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.rail-search-input:focus { outline: none; }
|
.rail-search-input:focus { outline: none; }
|
||||||
.rail-search-input::-webkit-search-cancel-button { display: none; }
|
.rail-search-input::-webkit-search-cancel-button { display: none; }
|
||||||
|
|
||||||
.btn-search-clear {
|
/* Sits inside the search field: no padding, and must not flex. */
|
||||||
background: none;
|
.btn-search-clear { padding: 0; flex-shrink: 0; }
|
||||||
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); }
|
|
||||||
|
|
||||||
.rail-state {
|
.rail-state {
|
||||||
padding: 1rem 0.65rem;
|
padding: 1rem 0.65rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Note list */
|
/* Note list */
|
||||||
@@ -548,16 +506,16 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--fs-border-color) 60%, transparent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
border-right: 2px solid transparent;
|
border-right: 2px solid transparent;
|
||||||
transition: background 0.12s;
|
transition: background 0.12s;
|
||||||
}
|
}
|
||||||
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
|
.note-row:hover { background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover)); }
|
||||||
.note-row.active {
|
.note-row.active {
|
||||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover));
|
||||||
border-right-color: var(--color-primary);
|
border-right-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-row-main {
|
.note-row-main {
|
||||||
@@ -573,12 +531,12 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-row-age {
|
.note-row-age {
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -591,8 +549,8 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
|
|
||||||
.note-tag-pill {
|
.note-tag-pill {
|
||||||
font-size: 0.58rem;
|
font-size: 0.58rem;
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 10%, transparent);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0 0.3rem;
|
padding: 0 0.3rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -601,13 +559,13 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
max-width: 5rem;
|
max-width: 5rem;
|
||||||
}
|
}
|
||||||
.note-tag-pill.tag-match {
|
.note-tag-pill.tag-match {
|
||||||
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 22%, transparent);
|
||||||
outline: 1px solid var(--color-primary);
|
outline: 1px solid var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-tag-more {
|
.note-tag-more {
|
||||||
font-size: 0.58rem;
|
font-size: 0.58rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,99 +575,7 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
align-items: center;
|
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; }
|
.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 */
|
/* Editor UI */
|
||||||
.panel-header {
|
.panel-header {
|
||||||
@@ -727,27 +593,10 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unsaved { font-size: 0.72rem; color: var(--color-text-muted); }
|
.unsaved { font-size: 0.72rem; color: var(--fs-text-tertiary); }
|
||||||
.saving-txt { font-size: 0.72rem; color: var(--color-primary); }
|
.saving-txt { font-size: 0.72rem; color: var(--fs-accent); }
|
||||||
|
|
||||||
/* Moss action-primary per Hybrid */
|
/* 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 {
|
.note-title-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -756,14 +605,14 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.25;
|
line-height: 1.25;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: 'Fraunces', serif;
|
font-family: 'Fraunces', serif;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
.note-title-input:focus { outline: none; }
|
.note-title-input:focus { outline: none; }
|
||||||
.note-title-input::placeholder {
|
.note-title-input::placeholder {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-row {
|
.tag-row {
|
||||||
@@ -775,20 +624,7 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
}
|
}
|
||||||
.tag-row > :first-child { flex: 1; min-width: 0; }
|
.tag-row > :first-child { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
.btn-suggest-tags {
|
.btn-suggest-tags { flex-shrink: 0; align-self: center; }
|
||||||
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; }
|
|
||||||
|
|
||||||
.tag-suggestions {
|
.tag-suggestions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -796,42 +632,27 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
padding: 0.35rem 0.6rem;
|
padding: 0.35rem 0.6rem;
|
||||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover));
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.tag-suggestions-label { font-size: 0.72rem; color: var(--color-text-muted); flex-shrink: 0; }
|
.tag-suggestions-label { font-size: 0.72rem; color: var(--fs-text-tertiary); flex-shrink: 0; }
|
||||||
|
|
||||||
.btn-tag-suggestion {
|
.btn-tag-suggestion {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.15rem 0.55rem;
|
padding: 0.15rem 0.55rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.btn-tag-suggestion:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
.btn-tag-suggestion:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||||
.btn-tag-suggestion.applied {
|
.btn-tag-suggestion.applied {
|
||||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
.link-suggest-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -839,12 +660,12 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.3rem 0.6rem;
|
padding: 0.3rem 0.6rem;
|
||||||
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
|
background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover));
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.link-suggest-label {
|
.link-suggest-label {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -854,32 +675,15 @@ defineExpose({ reload: loadProjectNotes });
|
|||||||
|
|
||||||
.btn-chip-link {
|
.btn-chip-link {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-primary);
|
border: 1px solid var(--fs-accent);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.1rem 0.45rem;
|
padding: 0.1rem 0.45rem;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.btn-chip-link:hover { background: color-mix(in srgb, var(--color-primary) 15%, transparent); }
|
.btn-chip-link:hover { background: color-mix(in srgb, var(--fs-accent) 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>
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useToastStore } from "@/stores/toast";
|
|||||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||||
import { renderMarkdown } from "@/utils/markdown";
|
import { renderMarkdown } from "@/utils/markdown";
|
||||||
import { Trash2, X } from "lucide-vue-next";
|
import { Trash2, X } from "lucide-vue-next";
|
||||||
|
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
||||||
|
|
||||||
const props = defineProps<{ projectId: number }>();
|
const props = defineProps<{ projectId: number }>();
|
||||||
|
|
||||||
@@ -198,20 +199,6 @@ function cancelDeleteTask() {
|
|||||||
deleteConfirmPending.value = false;
|
deleteConfirmPending.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
const d = new Date(iso);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - d.getTime();
|
|
||||||
const diffMin = Math.floor(diffMs / 60_000);
|
|
||||||
const diffHrs = Math.floor(diffMs / 3_600_000);
|
|
||||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
|
||||||
if (diffMin < 1) return "just now";
|
|
||||||
if (diffMin < 60) return `${diffMin}m ago`;
|
|
||||||
if (diffHrs < 24) return `${diffHrs}h ago`;
|
|
||||||
if (diffDays < 7) return `${diffDays}d ago`;
|
|
||||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadAll);
|
onMounted(loadAll);
|
||||||
defineExpose({ reload: loadAll });
|
defineExpose({ reload: loadAll });
|
||||||
</script>
|
</script>
|
||||||
@@ -232,7 +219,7 @@ defineExpose({ reload: loadAll });
|
|||||||
placeholder="New task..."
|
placeholder="New task..."
|
||||||
@keydown.enter="addTask"
|
@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>
|
||||||
|
|
||||||
<div v-if="loading" class="state-msg">Loading...</div>
|
<div v-if="loading" class="state-msg">Loading...</div>
|
||||||
@@ -256,7 +243,7 @@ defineExpose({ reload: loadAll });
|
|||||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
||||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
||||||
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
||||||
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
|
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
|
<li v-if="groupedTasks.noMilestone.length === 0" class="empty-group">No tasks</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -281,7 +268,7 @@ defineExpose({ reload: loadAll });
|
|||||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
||||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
||||||
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
||||||
<span class="task-age">{{ formatDate(task.updated_at) }}</span>
|
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
|
<li v-if="msTasks.length === 0" class="empty-group">No tasks</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -293,18 +280,18 @@ defineExpose({ reload: loadAll });
|
|||||||
<Transition name="detail-fade">
|
<Transition name="detail-fade">
|
||||||
<div v-if="activeTask" class="task-detail">
|
<div v-if="activeTask" class="task-detail">
|
||||||
<div class="detail-header">
|
<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">
|
<span :class="['status-badge', `status-${activeTask.status}`]" @click="cycleStatus(activeTask, $event)" title="Click to cycle status">
|
||||||
{{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }}
|
{{ STATUS_ICON[activeTask.status] ?? "○" }} {{ activeTask.status.replace("_", " ") }}
|
||||||
</span>
|
</span>
|
||||||
<template v-if="deleteConfirmPending">
|
<template v-if="deleteConfirmPending">
|
||||||
<button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
|
<button class="btn-danger-outline btn-inline 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-text" aria-label="Cancel delete" @click="cancelDeleteTask"><X :size="16" /></button>
|
||||||
</template>
|
</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" />
|
<Trash2 :size="16" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<h3 class="detail-title">{{ activeTask.title }}</h3>
|
<h3 class="detail-title">{{ activeTask.title }}</h3>
|
||||||
@@ -344,8 +331,8 @@ defineExpose({ reload: loadAll });
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-surface);
|
background: var(--fs-surface-hover);
|
||||||
border-right: 1px solid var(--color-border);
|
border-right: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── List view ── */
|
/* ── List view ── */
|
||||||
@@ -360,17 +347,17 @@ defineExpose({ reload: loadAll });
|
|||||||
flex: 0 0 44%;
|
flex: 0 0 44%;
|
||||||
}
|
}
|
||||||
.task-active {
|
.task-active {
|
||||||
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface)) !important;
|
background: color-mix(in srgb, var(--fs-accent) 6%, var(--fs-surface-hover)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.6rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-title {
|
.panel-title {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
@@ -381,32 +368,22 @@ defineExpose({ reload: loadAll });
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.45rem 0.6rem;
|
padding: 0.45rem 0.6rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-add-input {
|
.task-add-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--color-input-bg, var(--color-bg));
|
background: var(--fs-surface-page);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 0.28rem 0.5rem;
|
padding: 0.28rem 0.5rem;
|
||||||
font-size: 0.83rem;
|
font-size: 0.83rem;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.task-add-input:focus { outline: none; border-color: var(--color-primary); }
|
.task-add-input:focus { outline: none; border-color: var(--fs-accent); }
|
||||||
|
|
||||||
.btn-add {
|
.btn-add { font-size: 1rem; } /* a '+' glyph, not a label */
|
||||||
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; }
|
|
||||||
|
|
||||||
.groups-scroll {
|
.groups-scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -414,7 +391,7 @@ defineExpose({ reload: loadAll });
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ms-group {
|
.ms-group {
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ms-group-header {
|
.ms-group-header {
|
||||||
@@ -423,18 +400,18 @@ defineExpose({ reload: loadAll });
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.4rem 0.65rem;
|
padding: 0.4rem 0.65rem;
|
||||||
background: var(--color-surface-raised, color-mix(in srgb, var(--color-surface) 92%, var(--color-text)));
|
background: var(--fs-surface-raised);
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.ms-group-header:hover { background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)); }
|
.ms-group-header:hover { background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-hover)); }
|
||||||
|
|
||||||
.ms-chevron { font-size: 0.6rem; color: var(--color-text-muted); width: 0.8rem; }
|
.ms-chevron { font-size: 0.6rem; color: var(--fs-text-tertiary); width: 0.8rem; }
|
||||||
.ms-name { flex: 1; font-weight: 500; font-size: 0.8rem; }
|
.ms-name { flex: 1; font-weight: 500; font-size: 0.8rem; }
|
||||||
.ms-count { font-size: 0.72rem; color: var(--color-text-muted); background: var(--color-bg); border-radius: 10px; padding: 0 0.4rem; }
|
.ms-count { font-size: 0.72rem; color: var(--fs-text-tertiary); background: var(--fs-surface-page); border-radius: 10px; padding: 0 0.4rem; }
|
||||||
|
|
||||||
.ms-status {
|
.ms-status {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
@@ -442,8 +419,8 @@ defineExpose({ reload: loadAll });
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
.ms-status-active { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
|
.ms-status-active { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent); }
|
||||||
.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(--fs-success) 15%, transparent); color: var(--fs-success); }
|
||||||
|
|
||||||
.task-items {
|
.task-items {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -457,9 +434,9 @@ defineExpose({ reload: loadAll });
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.35rem 0.65rem 0.35rem 1.4rem;
|
padding: 0.35rem 0.65rem 0.35rem 1.4rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);
|
border-bottom: 1px solid color-mix(in srgb, var(--fs-border-color) 50%, transparent);
|
||||||
}
|
}
|
||||||
.task-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
|
.task-row:hover { background: color-mix(in srgb, var(--fs-accent) 5%, var(--fs-surface-hover)); }
|
||||||
.task-row:last-child { border-bottom: none; }
|
.task-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
@@ -467,7 +444,7 @@ defineExpose({ reload: loadAll });
|
|||||||
width: 1.35rem;
|
width: 1.35rem;
|
||||||
height: 1.35rem;
|
height: 1.35rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1.5px solid var(--color-border);
|
border: 1.5px solid var(--fs-border-color);
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.62rem;
|
font-size: 0.62rem;
|
||||||
@@ -475,8 +452,8 @@ defineExpose({ reload: loadAll });
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.status-dot.status-in_progress { border-color: var(--color-primary); color: var(--color-primary); }
|
.status-dot.status-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||||
.status-dot.status-done { border-color: var(--color-success, #27ae60); color: var(--color-success, #27ae60); }
|
.status-dot.status-done { border-color: var(--fs-success); color: var(--fs-success); }
|
||||||
|
|
||||||
.task-title {
|
.task-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -484,20 +461,20 @@ defineExpose({ reload: loadAll });
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.task-title.done { text-decoration: line-through; color: var(--color-text-muted); }
|
.task-title.done { text-decoration: line-through; color: var(--fs-text-tertiary); }
|
||||||
|
|
||||||
.task-age {
|
.task-age {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--color-text-muted); }
|
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--fs-text-tertiary); }
|
||||||
|
|
||||||
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--color-text-muted); }
|
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--fs-text-tertiary); }
|
||||||
|
|
||||||
/* ── Detail pane (bottom split) ── */
|
/* ── Detail pane (bottom split) ── */
|
||||||
.task-detail {
|
.task-detail {
|
||||||
@@ -506,8 +483,8 @@ defineExpose({ reload: loadAll });
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-surface);
|
background: var(--fs-surface-hover);
|
||||||
border-top: 2px solid var(--color-border);
|
border-top: 2px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-header {
|
.detail-header {
|
||||||
@@ -515,7 +492,7 @@ defineExpose({ reload: loadAll });
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.6rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,31 +502,21 @@ defineExpose({ reload: loadAll });
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 1.5px solid var(--color-border);
|
border: 1.5px solid var(--fs-border-color);
|
||||||
background: none;
|
background: none;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
margin-left: auto;
|
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-in_progress { border-color: var(--fs-accent); color: var(--fs-accent); background: color-mix(in srgb, var(--fs-accent) 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(--fs-success); color: var(--fs-success); background: color-mix(in srgb, var(--fs-success) 10%, transparent); }
|
||||||
|
|
||||||
.btn-edit-task {
|
.btn-edit-task { margin-left: 0.25rem; }
|
||||||
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:hover { text-decoration: underline; }
|
.btn-edit-task:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.detail-body {
|
.detail-body {
|
||||||
padding: 0.5rem 0.75rem 0.5rem;
|
padding: 0.5rem 0.75rem 0.5rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
max-height: 40%;
|
max-height: 40%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -557,60 +524,20 @@ defineExpose({ reload: loadAll });
|
|||||||
|
|
||||||
.body-loading {
|
.body-loading {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-body .prose {
|
.detail-body .prose {
|
||||||
font-size: 0.83rem;
|
font-size: 0.83rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-delete-task {
|
.btn-delete-task { margin-left: 0.25rem; }
|
||||||
background: none;
|
.btn-delete-task:hover { color: var(--fs-action-destructive); }
|
||||||
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:hover { color: var(--color-action-destructive); }
|
|
||||||
|
|
||||||
.btn-delete-confirm {
|
.btn-delete-confirm { margin-left: 0.25rem; }
|
||||||
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-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 {
|
.detail-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -623,9 +550,9 @@ defineExpose({ reload: loadAll });
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -633,20 +560,20 @@ defineExpose({ reload: loadAll });
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding: 0.15rem 0.4rem;
|
padding: 0.15rem 0.4rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
max-width: 140px;
|
max-width: 140px;
|
||||||
}
|
}
|
||||||
.milestone-select:disabled { opacity: 0.5; cursor: default; }
|
.milestone-select:disabled { opacity: 0.5; cursor: default; }
|
||||||
.milestone-select:focus { outline: none; border-color: var(--color-primary); }
|
.milestone-select:focus { outline: none; border-color: var(--fs-accent); }
|
||||||
|
|
||||||
.detail-log {
|
.detail-log {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0 0.6rem 0.6rem;
|
padding: 0 0.6rem 0.6rem;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Detail fade transition */
|
/* Detail fade transition */
|
||||||
@@ -669,25 +596,15 @@ defineExpose({ reload: loadAll });
|
|||||||
/* Due date on task rows */
|
/* Due date on task rows */
|
||||||
.task-due {
|
.task-due {
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.task-due.overdue {
|
.task-due.overdue {
|
||||||
color: var(--color-danger, #e74c3c);
|
color: var(--fs-error);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Close detail button */
|
/* Close detail button */
|
||||||
.btn-close-detail {
|
.btn-close-detail { margin-left: 0.2rem; }
|
||||||
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); }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -46,13 +46,19 @@ watch(() => props.projectId, load);
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.plan-rules {
|
.plan-rules {
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
border-top: 1px solid var(--color-border, #2a2a2e);
|
border-top: 1px solid var(--fs-border-color);
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
.plan-rules h3 {
|
.plan-rules h3 {
|
||||||
font-size: 0.9em; opacity: 0.7;
|
font-size: 0.9em; opacity: 0.7;
|
||||||
text-transform: uppercase; letter-spacing: 0.05em;
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
/* `.rb` is deliberately bare — it exists to namespace the two heading rules
|
||||||
|
below, and its children carry their own spacing (the h4 keeps the UA
|
||||||
|
margin-top that separates one rulebook group from the next). Nothing here
|
||||||
|
assumes a flex or grid parent, which is the tell that distinguishes this
|
||||||
|
from a base rule someone deleted (#2444). Stated so the next reader doesn't
|
||||||
|
re-open the question. */
|
||||||
.rb h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.25rem; }
|
.rb h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.25rem; }
|
||||||
.rb h5 {
|
.rb h5 {
|
||||||
font-size: 0.8em; opacity: 0.7;
|
font-size: 0.8em; opacity: 0.7;
|
||||||
@@ -60,7 +66,7 @@ watch(() => props.projectId, load);
|
|||||||
}
|
}
|
||||||
.plan-rules ul {
|
.plan-rules ul {
|
||||||
list-style: none; padding-left: 0.75rem; margin: 0.25rem 0;
|
list-style: none; padding-left: 0.75rem; margin: 0.25rem 0;
|
||||||
border-left: 2px solid var(--color-primary, #6366f1);
|
border-left: 2px solid var(--fs-accent);
|
||||||
}
|
}
|
||||||
.plan-rules li { margin: 0.35rem 0; font-size: 0.92em; }
|
.plan-rules li { margin: 0.35rem 0; font-size: 0.92em; }
|
||||||
.truncated { opacity: 0.7; font-style: italic; font-size: 0.85em; }
|
.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; }
|
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||||||
.chip {
|
.chip {
|
||||||
display: inline-flex; align-items: center; gap: 0.25rem;
|
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||||
background: var(--color-primary-bg, rgba(99,102,241,0.15));
|
background: var(--fs-accent-soft);
|
||||||
padding: 0.25rem 0.5rem; border-radius: 999px;
|
padding: 0.25rem 0.5rem; border-radius: 999px;
|
||||||
}
|
}
|
||||||
.chip a { cursor: pointer; }
|
.chip a { cursor: pointer; }
|
||||||
@@ -337,25 +337,29 @@ h3 {
|
|||||||
.chip-remove:hover { opacity: 1; }
|
.chip-remove:hover { opacity: 1; }
|
||||||
.add {
|
.add {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px dashed var(--color-border, #2a2a2e);
|
border: 1px dashed var(--fs-border-color);
|
||||||
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
|
padding: 0.25rem 0.75rem; border-radius: 999px; cursor: pointer;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
background: var(--color-bg, #111113); color: inherit;
|
background: var(--fs-surface-page); color: inherit;
|
||||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
}
|
}
|
||||||
.applicable { margin-top: 2rem; }
|
.applicable { margin-top: 2rem; }
|
||||||
.rb-group { margin-bottom: 1.5rem; }
|
.rb-group { margin-bottom: 1.5rem; }
|
||||||
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; }
|
.rb-group h4 { font-family: Fraunces, serif; font-style: italic; margin-bottom: 0.5rem; }
|
||||||
|
/* `.topic-group` is deliberately bare — a namespace for the two h5 rules (this
|
||||||
|
one and the flex row further down), with the h5's own margin-top doing the
|
||||||
|
separating. Its children assume nothing about it, which is what tells it
|
||||||
|
apart from a base rule someone deleted (#2444). */
|
||||||
.topic-group h5 {
|
.topic-group h5 {
|
||||||
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
font-size: 0.85em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
margin-top: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
ul { list-style: none; padding: 0; margin: 0; }
|
ul { list-style: none; padding: 0; margin: 0; }
|
||||||
.rule {
|
.rule {
|
||||||
border-left: 2px solid var(--color-primary, #6366f1);
|
border-left: 2px solid var(--fs-accent);
|
||||||
padding-left: 0.75rem; margin: 0.5rem 0;
|
padding-left: 0.75rem; margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
.rule-head { cursor: pointer; }
|
.rule-head { cursor: pointer; }
|
||||||
@@ -363,12 +367,12 @@ ul { list-style: none; padding: 0; margin: 0; }
|
|||||||
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
|
.rule-statement { display: block; opacity: 0.85; margin-top: 0.25rem; }
|
||||||
.rule-detail {
|
.rule-detail {
|
||||||
margin-top: 0.5rem; padding: 0.5rem;
|
margin-top: 0.5rem; padding: 0.5rem;
|
||||||
background: var(--color-bg, #111113); border-radius: 6px;
|
background: var(--fs-surface-page); border-radius: 6px;
|
||||||
}
|
}
|
||||||
.rule-detail > div { margin-bottom: 0.5rem; }
|
.rule-detail > div { margin-bottom: 0.5rem; }
|
||||||
.edit-link {
|
.edit-link {
|
||||||
background: none; border: none; cursor: pointer;
|
background: none; border: none; cursor: pointer;
|
||||||
color: var(--color-primary, #6366f1); padding: 0.5rem 0 0 0;
|
color: var(--fs-accent); padding: 0.5rem 0 0 0;
|
||||||
}
|
}
|
||||||
.empty, .truncated { opacity: 0.7; font-style: italic; }
|
.empty, .truncated { opacity: 0.7; font-style: italic; }
|
||||||
.empty a { cursor: pointer; text-decoration: underline; }
|
.empty a { cursor: pointer; text-decoration: underline; }
|
||||||
@@ -377,18 +381,18 @@ ul { list-style: none; padding: 0; margin: 0; }
|
|||||||
.new-rule-form {
|
.new-rule-form {
|
||||||
display: flex; flex-direction: column; gap: 0.5rem;
|
display: flex; flex-direction: column; gap: 0.5rem;
|
||||||
padding: 0.75rem; margin: 0.5rem 0;
|
padding: 0.75rem; margin: 0.5rem 0;
|
||||||
background: var(--color-bg, #111113);
|
background: var(--fs-surface-page);
|
||||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||||
}
|
}
|
||||||
.new-rule-form input, .new-rule-form textarea {
|
.new-rule-form input, .new-rule-form textarea {
|
||||||
background: var(--color-surface, #18181b); color: inherit;
|
background: var(--fs-surface-hover); color: inherit;
|
||||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||||
padding: 0.5rem; font: inherit; resize: vertical;
|
padding: 0.5rem; font: inherit; resize: vertical;
|
||||||
}
|
}
|
||||||
.rule-list { margin-top: 0.5rem; }
|
.rule-list { margin-top: 0.5rem; }
|
||||||
.delete-link {
|
.delete-link {
|
||||||
background: none; border: none; cursor: pointer;
|
background: none; border: none; cursor: pointer;
|
||||||
color: var(--color-destructive, #b85a4a); padding: 0.5rem 0 0 0;
|
color: var(--fs-destructive); padding: 0.5rem 0 0 0;
|
||||||
}
|
}
|
||||||
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
|
/* Per-rule / per-topic suppress affordance — quiet by default, reveal on hover */
|
||||||
.topic-group h5 {
|
.topic-group h5 {
|
||||||
@@ -400,14 +404,14 @@ ul { list-style: none; padding: 0; margin: 0; }
|
|||||||
.rule-head-text { flex: 1; cursor: pointer; }
|
.rule-head-text { flex: 1; cursor: pointer; }
|
||||||
.skip-btn {
|
.skip-btn {
|
||||||
background: none; border: none; cursor: pointer;
|
background: none; border: none; cursor: pointer;
|
||||||
color: var(--color-muted, #888); font-size: 0.75rem;
|
color: var(--fs-text-tertiary); font-size: 0.75rem;
|
||||||
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
|
padding: 0.1rem 0.4rem; opacity: 0; transition: opacity 0.15s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.topic-group h5:hover .skip-btn,
|
.topic-group h5:hover .skip-btn,
|
||||||
.rule:hover .skip-btn,
|
.rule:hover .skip-btn,
|
||||||
.skip-btn:focus { opacity: 1; }
|
.skip-btn:focus { opacity: 1; }
|
||||||
.skip-btn:hover { color: var(--color-destructive, #b85a4a); }
|
.skip-btn:hover { color: var(--fs-destructive); }
|
||||||
/* Suppressed section */
|
/* Suppressed section */
|
||||||
.suppressed { margin-top: 1.5rem; }
|
.suppressed { margin-top: 1.5rem; }
|
||||||
.suppressed-toggle {
|
.suppressed-toggle {
|
||||||
@@ -426,13 +430,13 @@ ul { list-style: none; padding: 0; margin: 0; }
|
|||||||
.suppressed-kind {
|
.suppressed-kind {
|
||||||
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
|
font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
padding: 0.1rem 0.4rem; border-radius: 3px;
|
padding: 0.1rem 0.4rem; border-radius: 3px;
|
||||||
background: var(--color-bg, #111113);
|
background: var(--fs-surface-page);
|
||||||
border: 1px solid var(--color-border, #2a2a2e);
|
border: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
.suppressed-path { flex: 1; }
|
.suppressed-path { flex: 1; }
|
||||||
.reenable-btn {
|
.reenable-btn {
|
||||||
background: none; border: none; cursor: pointer;
|
background: none; border: none; cursor: pointer;
|
||||||
color: var(--color-primary, #6366f1); font-size: 0.85em;
|
color: var(--fs-accent); font-size: 0.85em;
|
||||||
}
|
}
|
||||||
.reenable-btn:hover { text-decoration: underline; }
|
.reenable-btn:hover { text-decoration: underline; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ watch(() => props.ruleId, load);
|
|||||||
.slide-over {
|
.slide-over {
|
||||||
position: fixed; top: 0; right: 0; bottom: 0;
|
position: fixed; top: 0; right: 0; bottom: 0;
|
||||||
width: min(520px, 90vw);
|
width: min(520px, 90vw);
|
||||||
background: var(--color-surface, #18181b);
|
background: var(--fs-surface-hover);
|
||||||
border-left: 2px solid var(--color-primary, #6366f1);
|
border-left: 2px solid var(--fs-accent);
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
|
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.3);
|
||||||
@@ -110,11 +110,11 @@ header h2 {
|
|||||||
font-family: Fraunces, serif; font-style: italic;
|
font-family: Fraunces, serif; font-style: italic;
|
||||||
}
|
}
|
||||||
label { display: block; margin-bottom: 1rem; }
|
label { display: block; margin-bottom: 1rem; }
|
||||||
.required { color: var(--color-primary, #6366f1); }
|
.required { color: var(--fs-accent); }
|
||||||
input, textarea {
|
input, textarea {
|
||||||
width: 100%; margin-top: 0.25rem;
|
width: 100%; margin-top: 0.25rem;
|
||||||
background: var(--color-bg, #111113); color: inherit;
|
background: var(--fs-surface-page); color: inherit;
|
||||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||||
padding: 0.5rem; font: inherit;
|
padding: 0.5rem; font: inherit;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,18 +22,18 @@ const emit = defineEmits<{
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
|
||||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
li {
|
li {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border-left: 2px solid var(--color-primary, #6366f1);
|
border-left: 2px solid var(--fs-accent);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
li:hover { background: var(--fs-surface-hover); }
|
||||||
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
.title { font-family: Fraunces, serif; font-style: italic; font-size: 1.05em; }
|
||||||
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
.statement { font-size: 0.9em; opacity: 0.8; margin-top: 0.25rem; }
|
||||||
.new-rule { cursor: pointer; }
|
.new-rule { cursor: pointer; }
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ watch(() => props.rulebookId, () => {/* re-render of isSubscribed from existing
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
|
||||||
header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
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; }
|
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||||
.always-on-toggle {
|
.always-on-toggle {
|
||||||
@@ -133,18 +133,23 @@ header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem
|
|||||||
.always-on-toggle input { cursor: pointer; }
|
.always-on-toggle input { cursor: pointer; }
|
||||||
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
ul { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
li { padding: 0.5rem; cursor: pointer; border-radius: 6px; }
|
||||||
li.active { background: var(--color-primary-bg, rgba(99,102,241,0.15)); }
|
li.active { background: var(--fs-accent-soft); }
|
||||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
li:hover { background: var(--fs-surface-hover); }
|
||||||
|
/* `.new-topic` and `.sub-list` are deliberately bare (#2444). The first wraps a
|
||||||
|
button-or-form whose children style themselves; the second is a `<ul>`, and
|
||||||
|
the bare `ul` rule above already gives it list-style, padding and margin —
|
||||||
|
a base a class-name check cannot see, since it comes from an element
|
||||||
|
selector. Both namespace descendant rules and assume nothing about layout. */
|
||||||
.new-topic input {
|
.new-topic input {
|
||||||
width: 100%; margin-bottom: 0.5rem;
|
width: 100%; margin-bottom: 0.5rem;
|
||||||
background: var(--color-bg, #111113); color: inherit;
|
background: var(--fs-surface-page); color: inherit;
|
||||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
.form-buttons { display: flex; gap: 0.5rem; }
|
.form-buttons { display: flex; gap: 0.5rem; }
|
||||||
.subscriptions {
|
.subscriptions {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
border-top: 1px solid var(--color-border, #2a2a2e);
|
border-top: 1px solid var(--fs-border-color);
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
|
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
|
|||||||
@@ -48,27 +48,27 @@ async function submitNew() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.pane { background: var(--color-surface, #18181b); padding: 1rem; overflow-y: auto; }
|
.pane { background: var(--fs-surface-hover); padding: 1rem; overflow-y: auto; }
|
||||||
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
header h2 { font-family: Fraunces, serif; font-style: italic; margin: 0 0 0.5rem 0; }
|
||||||
ul { list-style: none; padding: 0; margin: 1rem 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 { 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.active { background: var(--fs-accent-soft); }
|
||||||
li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
|
li:hover { background: var(--fs-surface-hover); }
|
||||||
.always-on-badge {
|
.always-on-badge {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
padding: 0.1rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: var(--color-accent, rgba(91,74,138,0.25));
|
background: var(--fs-accent);
|
||||||
color: var(--color-accent-fg, inherit);
|
color: var(--fs-text-on-action);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.new-rulebook { margin-top: 1rem; }
|
.new-rulebook { margin-top: 1rem; }
|
||||||
.new-rulebook input {
|
.new-rulebook input {
|
||||||
width: 100%; margin-bottom: 0.5rem;
|
width: 100%; margin-bottom: 0.5rem;
|
||||||
background: var(--color-bg, #111113); color: inherit;
|
background: var(--fs-surface-page); color: inherit;
|
||||||
border: 1px solid var(--color-border, #2a2a2e); border-radius: 6px;
|
border: 1px solid var(--fs-border-color); border-radius: 6px;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
.form-buttons { display: flex; gap: 0.5rem; }
|
.form-buttons { display: flex; gap: 0.5rem; }
|
||||||
|
|||||||
@@ -9,3 +9,15 @@ export function relativeTime(iso: string): string {
|
|||||||
const days = Math.floor(hours / 24);
|
const days = Math.floor(hours / 24);
|
||||||
return `${days}d ago`;
|
return `${days}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* relativeTime() for the recent past, a short date once it's a week old —
|
||||||
|
* the workspace panels' list-row timestamp ("3h ago" / "Jan 15"). Two
|
||||||
|
* panels used to carry identical copies of this.
|
||||||
|
*/
|
||||||
|
export function relativeTimeOrDate(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const days = Math.floor((Date.now() - d.getTime()) / 86_400_000);
|
||||||
|
if (days < 7) return relativeTime(iso);
|
||||||
|
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { createPinia } from "pinia";
|
|||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
import "./assets/theme.css";
|
import "./assets/theme.css";
|
||||||
|
// After theme.css — it consumes the tokens declared there.
|
||||||
|
import "./assets/components.css";
|
||||||
import "./assets/prose.css";
|
import "./assets/prose.css";
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|||||||
@@ -69,6 +69,26 @@ const router = createRouter({
|
|||||||
name: "note-edit",
|
name: "note-edit",
|
||||||
component: () => import("@/views/NoteEditorView.vue"),
|
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",
|
path: "/graph",
|
||||||
name: "graph",
|
name: "graph",
|
||||||
@@ -89,6 +109,16 @@ const router = createRouter({
|
|||||||
name: "rules",
|
name: "rules",
|
||||||
component: () => import("@/views/RulesView.vue"),
|
component: () => import("@/views/RulesView.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The design systems this install RECORDS — for the projects it tracks,
|
||||||
|
// not for the install itself. There was a sibling `/design` that read the
|
||||||
|
// running app's own stylesheet out of the browser; it could only ever
|
||||||
|
// inspect the instance it was served from, which made it a mirror rather
|
||||||
|
// than a tool (#274).
|
||||||
|
path: "/design-systems",
|
||||||
|
name: "design-systems",
|
||||||
|
component: () => import("@/views/DesignSystemsView.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/tasks",
|
path: "/tasks",
|
||||||
redirect: "/",
|
redirect: "/",
|
||||||
@@ -108,11 +138,6 @@ const router = createRouter({
|
|||||||
name: "shared-with-me",
|
name: "shared-with-me",
|
||||||
component: () => import("@/views/SharedWithMeView.vue"),
|
component: () => import("@/views/SharedWithMeView.vue"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/calendar",
|
|
||||||
name: "calendar",
|
|
||||||
component: () => import("@/views/CalendarView.vue"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/settings",
|
path: "/settings",
|
||||||
name: "settings",
|
name: "settings",
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
project_id?: number | null;
|
project_id?: number | null;
|
||||||
milestone_id?: number | null;
|
milestone_id?: number | null;
|
||||||
note_type?: string;
|
note_type?: string;
|
||||||
metadata?: Record<string, string> | null;
|
|
||||||
}): Promise<Note> {
|
}): Promise<Note> {
|
||||||
try {
|
try {
|
||||||
return await apiPost<Note>("/api/notes", data);
|
return await apiPost<Note>("/api/notes", data);
|
||||||
@@ -43,7 +42,7 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
|
|
||||||
async function updateNote(
|
async function updateNote(
|
||||||
id: number,
|
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> {
|
): Promise<Note> {
|
||||||
try {
|
try {
|
||||||
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
const note = await apiPut<Note>(`/api/notes/${id}`, data);
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import type { System } from "@/api/systems";
|
|||||||
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
|
||||||
export type TaskPriority = "none" | "low" | "medium" | "high";
|
export type TaskPriority = "none" | "low" | "medium" | "high";
|
||||||
export type TaskKind = "work" | "plan" | "issue";
|
export type TaskKind = "work" | "plan" | "issue";
|
||||||
export type NoteType = "note" | "person" | "place" | "list" | "process";
|
export type NoteType = "note" | "process" | "snippet";
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
consolidated_at: string | null;
|
|
||||||
tags: string[];
|
tags: string[];
|
||||||
parent_id: number | null;
|
parent_id: number | null;
|
||||||
parent_title?: string | null;
|
parent_title?: string | null;
|
||||||
@@ -28,7 +27,6 @@ export interface Note {
|
|||||||
task_kind?: TaskKind;
|
task_kind?: TaskKind;
|
||||||
systems?: System[];
|
systems?: System[];
|
||||||
arose_from_id?: number | null;
|
arose_from_id?: number | null;
|
||||||
metadata: Record<string, string>;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,32 @@
|
|||||||
/** Shared date/time formatting helpers used across Calendar, Home, Knowledge, etc. */
|
|
||||||
|
|
||||||
function _isSameDay(a: Date, b: Date): boolean {
|
|
||||||
return a.getFullYear() === b.getFullYear() &&
|
|
||||||
a.getMonth() === b.getMonth() &&
|
|
||||||
a.getDate() === b.getDate()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "9:30 AM" */
|
|
||||||
export function fmtTime(dt: string): string {
|
|
||||||
return new Date(dt).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** "Mon, Jan 15" or "Mon, Jan 15, 9:30 AM" */
|
|
||||||
export function fmtDateTime(dt: string, allDay: boolean): string {
|
|
||||||
const d = new Date(dt)
|
|
||||||
const datePart = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
|
|
||||||
if (allDay) return datePart
|
|
||||||
return `${datePart}, ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Today 9:30 AM" / "Tomorrow 9:30 AM" / "Mon, Jan 15 9:30 AM"
|
* Shared date/time formatting — one rule per display shape. Views import
|
||||||
* For all-day events returns "Today" / "Tomorrow" / "Mon, Jan 15"
|
* these instead of carrying a local formatDate(): the 2026-08 shape audit
|
||||||
|
* found eight copies across views/components, three of them byte-identical.
|
||||||
|
* (The previous Calendar/Home helpers in this file had no callers left and
|
||||||
|
* were removed in the same pass.)
|
||||||
|
*
|
||||||
|
* Relative forms ("5m ago") live next door in composables/useRelativeTime.
|
||||||
*/
|
*/
|
||||||
export function fmtRelativeDateTime(dt: string, allDay: boolean): string {
|
|
||||||
try {
|
|
||||||
const d = new Date(dt)
|
|
||||||
const now = new Date()
|
|
||||||
const tomorrow = new Date(now)
|
|
||||||
tomorrow.setDate(now.getDate() + 1)
|
|
||||||
|
|
||||||
const timeStr = allDay ? "" : ` ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`
|
/** "Jan 15, 2026" — a date with no time of day (user created_at, key expiry). */
|
||||||
|
export function fmtDate(iso: string): string {
|
||||||
if (_isSameDay(d, now)) return `Today${timeStr}`
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
if (_isSameDay(d, tomorrow)) return `Tomorrow${timeStr}`
|
year: "numeric", month: "short", day: "numeric",
|
||||||
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + timeStr
|
});
|
||||||
} catch {
|
|
||||||
return dt
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** "Jan 15, 2026, 09:30 AM" — a full timestamp (task logs, version history). */
|
||||||
* Label-only: "Today" / "Tomorrow" / "Mon, Jan 15"
|
export function fmtStamp(iso: string): string {
|
||||||
*/
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
export function fmtDayLabel(dt: string): string {
|
month: "short", day: "numeric", year: "numeric",
|
||||||
try {
|
hour: "2-digit", minute: "2-digit",
|
||||||
const d = new Date(dt)
|
});
|
||||||
const now = new Date()
|
|
||||||
const tomorrow = new Date(now)
|
|
||||||
tomorrow.setDate(now.getDate() + 1)
|
|
||||||
if (_isSameDay(d, now)) return "Today"
|
|
||||||
if (_isSameDay(d, tomorrow)) return "Tomorrow"
|
|
||||||
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })
|
|
||||||
} catch {
|
|
||||||
return dt
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Jan 15" or "Jan 15, 9:30 AM" — compact, no weekday */
|
/** "Jan 15, 09:30:05 AM" — log-table timestamp: seconds matter, the year doesn't. */
|
||||||
export function fmtCompact(dt: string, allDay: boolean): string {
|
export function fmtLogStamp(iso: string): string {
|
||||||
const d = new Date(dt)
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
if (allDay) return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
month: "short", day: "numeric",
|
||||||
return d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })
|
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Turning a design system's RECORDED values into ones you can look at.
|
||||||
|
*
|
||||||
|
* Replaces `designTokens.ts` and `designDrift.ts`, which between them read the
|
||||||
|
* running app's own stylesheet — names out of a bundled `theme.css`, values out
|
||||||
|
* of `getComputedStyle(document.documentElement)`. That could only ever describe
|
||||||
|
* the install serving the page, and the design surface is for the projects an
|
||||||
|
* install TRACKS (#274). What is left here works on any system's record,
|
||||||
|
* including one for an app this browser has never loaded.
|
||||||
|
*
|
||||||
|
* Nothing in this module reads the document's own tokens or mutates the page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The base mode's key in `value_by_mode`, mirroring services/design_stylesheet. */
|
||||||
|
export const BASE_MODE = "base";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which declared value applies in `mode`.
|
||||||
|
*
|
||||||
|
* Falls back to base, which is the storage model rather than a convenience: a
|
||||||
|
* mode block is an OVERRIDE layer, so a token with no entry for the current
|
||||||
|
* mode is not missing — it is inheriting, exactly as the generated sheet has it.
|
||||||
|
*/
|
||||||
|
export function valueForMode(
|
||||||
|
valueByMode: Record<string, string>,
|
||||||
|
mode: string,
|
||||||
|
): string {
|
||||||
|
const own = valueByMode[mode];
|
||||||
|
if (own !== undefined && own !== "") return own;
|
||||||
|
return valueByMode[BASE_MODE] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every mode any token in the set declares, base first then the rest by name. */
|
||||||
|
export function modesPresent(
|
||||||
|
tokens: { value_by_mode: Record<string, string> }[],
|
||||||
|
): string[] {
|
||||||
|
const modes = new Set<string>();
|
||||||
|
for (const token of tokens) {
|
||||||
|
for (const [mode, value] of Object.entries(token.value_by_mode)) {
|
||||||
|
if (value) modes.add(mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rest = [...modes].filter((m) => m !== BASE_MODE).sort();
|
||||||
|
return modes.has(BASE_MODE) ? [BASE_MODE, ...rest] : rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve declared values the way a browser would, without applying them.
|
||||||
|
*
|
||||||
|
* A record holds `color-mix(in srgb, var(--fs-accent) 15%, transparent)`. Shown
|
||||||
|
* as text that is a string; shown as a swatch it needs `var()` substituted and
|
||||||
|
* the mix evaluated. Rather than write a CSS parser, set the declarations on an
|
||||||
|
* offscreen probe and read them back — the substitution is done by the
|
||||||
|
* implementation that would do it for real.
|
||||||
|
*
|
||||||
|
* Custom properties INHERIT, and `all: initial` does not reset them — so a probe
|
||||||
|
* sitting in this page would resolve any reference the record leaves undeclared
|
||||||
|
* against the surrounding app's own tokens. Previewing another project's system
|
||||||
|
* would then quietly borrow this one's palette wherever that system was
|
||||||
|
* incomplete, and a token the record already knows is broken (it shows up under
|
||||||
|
* `unknown_refs`) would render as though it were fine.
|
||||||
|
*
|
||||||
|
* So every name referenced but not declared is blanked on the probe first. It
|
||||||
|
* resolves to nothing, which is what the record says it is.
|
||||||
|
*/
|
||||||
|
const VAR_REFERENCE = /var\(\s*(--[A-Za-z0-9_-]+)/g;
|
||||||
|
|
||||||
|
export function resolveDeclared(declared: Map<string, string>): Map<string, string> {
|
||||||
|
const probe = document.createElement("div");
|
||||||
|
probe.style.display = "none";
|
||||||
|
for (const value of declared.values()) {
|
||||||
|
for (const match of value.matchAll(VAR_REFERENCE)) {
|
||||||
|
if (!declared.has(match[1])) probe.style.setProperty(match[1], " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [name, value] of declared) probe.style.setProperty(name, value);
|
||||||
|
document.body.appendChild(probe);
|
||||||
|
try {
|
||||||
|
const computed = getComputedStyle(probe);
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
for (const name of declared.keys()) {
|
||||||
|
out.set(name, computed.getPropertyValue(name).trim());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} finally {
|
||||||
|
probe.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
/** Cyclic color palette for milestone progress bars. */
|
/** Cyclic color palette for milestone progress bars. */
|
||||||
export const MILESTONE_PALETTE = [
|
export const MILESTONE_PALETTE = [
|
||||||
'var(--color-primary)',
|
'var(--fs-accent)',
|
||||||
'var(--color-success, #22c55e)',
|
'var(--fs-success, #22c55e)',
|
||||||
'#c98a00',
|
'#c98a00',
|
||||||
'#8b5cf6',
|
'#8b5cf6',
|
||||||
'var(--color-danger, #ef4444)',
|
'var(--fs-error, #ef4444)',
|
||||||
'#06b6d4',
|
'#06b6d4',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -11,13 +11,11 @@ interface ActiveProject {
|
|||||||
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
|
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
|
||||||
}
|
}
|
||||||
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
|
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 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 IssueRow { id: number; title: string; status: string; priority: string; project_id: number | null; project_title: string | null }
|
||||||
interface DashboardData {
|
interface DashboardData {
|
||||||
active_projects: ActiveProject[];
|
active_projects: ActiveProject[];
|
||||||
recently_completed: DoneItem[];
|
recently_completed: DoneItem[];
|
||||||
upcoming_events: UpcomingEvent[];
|
|
||||||
open_issues: IssueRow[];
|
open_issues: IssueRow[];
|
||||||
week_stats: WeekStats;
|
week_stats: WeekStats;
|
||||||
}
|
}
|
||||||
@@ -31,19 +29,11 @@ onMounted(async () => {
|
|||||||
try {
|
try {
|
||||||
data.value = await apiGet<DashboardData>("/api/dashboard");
|
data.value = await apiGet<DashboardData>("/api/dashboard");
|
||||||
} catch {
|
} catch {
|
||||||
data.value = { active_projects: [], recently_completed: [], upcoming_events: [], open_issues: [], 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 {
|
} finally {
|
||||||
loading.value = false;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -89,7 +79,7 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
|
|
||||||
<article v-for="p in data.active_projects" :key="p.id" class="proj-panel">
|
<article v-for="p in data.active_projects" :key="p.id" class="proj-panel">
|
||||||
<header class="proj-head">
|
<header class="proj-head">
|
||||||
<span class="proj-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
|
<span class="proj-dot" :style="{ background: p.color || 'var(--fs-accent)' }" />
|
||||||
<router-link :to="`/projects/${p.id}`" class="proj-title">{{ p.title }}</router-link>
|
<router-link :to="`/projects/${p.id}`" class="proj-title">{{ p.title }}</router-link>
|
||||||
<span class="proj-meta">{{ relativeTime(p.last_activity) }} · {{ p.open_count }} open</span>
|
<span class="proj-meta">{{ relativeTime(p.last_activity) }} · {{ p.open_count }} open</span>
|
||||||
</header>
|
</header>
|
||||||
@@ -150,17 +140,6 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="dash-label">This week</div>
|
<div class="dash-label">This week</div>
|
||||||
<div class="rail-card stats">
|
<div class="rail-card stats">
|
||||||
<span>✓ {{ data.week_stats.completed_this_week }} done</span>
|
<span>✓ {{ data.week_stats.completed_this_week }} done</span>
|
||||||
@@ -177,7 +156,7 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
:to="`/projects/${p.id}`"
|
:to="`/projects/${p.id}`"
|
||||||
class="pstat-row"
|
class="pstat-row"
|
||||||
>
|
>
|
||||||
<span class="pstat-dot" :style="{ background: p.color || 'var(--color-primary)' }" />
|
<span class="pstat-dot" :style="{ background: p.color || 'var(--fs-accent)' }" />
|
||||||
<span class="pstat-title">{{ p.title }}</span>
|
<span class="pstat-title">{{ p.title }}</span>
|
||||||
<span class="pstat-counts">{{ p.open_count }} open · {{ p.done_count }} done</span>
|
<span class="pstat-counts">{{ p.open_count }} open · {{ p.done_count }} done</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
@@ -197,74 +176,72 @@ function fmtEvent(e: UpcomingEvent): string {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dash-root { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
|
.dash-root { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
|
||||||
|
/* `.dash-head` is deliberately bare: a block header whose two children carry
|
||||||
|
all the spacing between them (h1 zeroed, .dash-sub margined). Nothing in it
|
||||||
|
assumes a flex parent, which is the tell for a deleted rule (#2444). */
|
||||||
.dash-head h1 { margin: 0; font-family: 'Fraunces', Georgia, serif; }
|
.dash-head h1 { margin: 0; font-family: 'Fraunces', Georgia, serif; }
|
||||||
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--color-muted); font-size: 0.9rem; }
|
.dash-sub { margin: 0.2rem 0 1.25rem; color: var(--fs-text-tertiary); font-size: 0.9rem; }
|
||||||
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-muted); margin-bottom: 0.6rem; }
|
.dash-label { display: block; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--fs-text-tertiary); margin-bottom: 0.6rem; }
|
||||||
.dash-empty { color: var(--color-muted); padding: 1rem 0; }
|
.dash-empty { color: var(--fs-text-tertiary); padding: 1rem 0; }
|
||||||
.dash-empty.card { padding: 1rem; border: 1px dashed var(--color-border); border-radius: 10px; }
|
.dash-empty.card { padding: 1rem; border: 1px dashed var(--fs-border-color); border-radius: 10px; }
|
||||||
|
|
||||||
.done-recent { margin-bottom: 1.5rem; }
|
.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 { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--fs-text-primary); font-size: 0.86rem; }
|
||||||
.done-row:hover { background: var(--color-hover); }
|
.done-row:hover { background: var(--fs-surface-hover); }
|
||||||
.done-mark { color: var(--color-primary); flex-shrink: 0; }
|
.done-mark { color: var(--fs-accent); flex-shrink: 0; }
|
||||||
.done-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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-meta { font-size: 0.72rem; color: var(--fs-text-tertiary); 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 { background: none; border: none; cursor: pointer; margin-top: 0.25rem; padding: 0.2rem 0.55rem; font-size: 0.78rem; color: var(--fs-accent); font-family: inherit; }
|
||||||
.done-more:hover { text-decoration: underline; }
|
.done-more:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.dash-cols { display: flex; gap: 1.25rem; align-items: flex-start; }
|
.dash-cols { display: flex; gap: 1.25rem; align-items: flex-start; }
|
||||||
.dash-main { flex: 1.7; min-width: 0; }
|
.dash-main { flex: 1.7; min-width: 0; }
|
||||||
.dash-rail { flex: 1; min-width: 240px; }
|
.dash-rail { flex: 1; min-width: 240px; }
|
||||||
|
|
||||||
.proj-panel { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.9rem 1rem; margin-bottom: 0.9rem; }
|
.proj-panel { background: var(--fs-surface-hover); border: 1px solid var(--fs-border-color); border-radius: 12px; padding: 0.9rem 1rem; margin-bottom: 0.9rem; }
|
||||||
.proj-head { display: flex; align-items: center; gap: 0.5rem; }
|
.proj-head { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
.proj-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
.proj-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||||
.proj-title { font-weight: 700; color: var(--color-text); text-decoration: none; }
|
.proj-title { font-weight: 700; color: var(--fs-text-primary); text-decoration: none; }
|
||||||
.proj-title:hover { color: var(--color-primary); }
|
.proj-title:hover { color: var(--fs-accent); }
|
||||||
.proj-meta { margin-left: auto; font-size: 0.74rem; color: var(--color-muted); }
|
.proj-meta { margin-left: auto; font-size: 0.74rem; color: var(--fs-text-tertiary); }
|
||||||
.bar { height: 5px; background: var(--color-border); border-radius: 3px; margin: 0.55rem 0 0.2rem; }
|
.bar { height: 5px; background: var(--fs-border-color); border-radius: 3px; margin: 0.55rem 0 0.2rem; }
|
||||||
.bar-fill { height: 5px; background: var(--color-primary); border-radius: 3px; }
|
.bar-fill { height: 5px; background: var(--fs-accent); border-radius: 3px; }
|
||||||
|
|
||||||
.ms-block { margin-top: 0.7rem; }
|
.ms-block { margin-top: 0.7rem; }
|
||||||
.ms-head { display: flex; align-items: baseline; gap: 0.5rem; margin-bottom: 0.3rem; }
|
.ms-head { display: flex; align-items: baseline; gap: 0.5rem; margin-bottom: 0.3rem; }
|
||||||
.ms-title { font-size: 0.82rem; font-weight: 600; color: var(--color-text); }
|
.ms-title { font-size: 0.82rem; font-weight: 600; color: var(--fs-text-primary); }
|
||||||
.ms-title.ms-none { color: var(--color-muted); font-weight: 500; }
|
.ms-title.ms-none { color: var(--fs-text-tertiary); font-weight: 500; }
|
||||||
.ms-pct { margin-left: auto; font-size: 0.72rem; color: var(--color-muted); }
|
.ms-pct { margin-left: auto; font-size: 0.72rem; color: var(--fs-text-tertiary); }
|
||||||
|
|
||||||
.task-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--color-text); font-size: 0.86rem; }
|
.task-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0.55rem; border-radius: 7px; text-decoration: none; color: var(--fs-text-primary); font-size: 0.86rem; }
|
||||||
.task-row:hover { background: var(--color-hover); }
|
.task-row:hover { background: var(--fs-surface-hover); }
|
||||||
.task-inprogress { border-left: 3px solid var(--color-primary); padding-left: calc(0.55rem - 3px); }
|
.task-inprogress { border-left: 3px solid var(--fs-accent); padding-left: calc(0.55rem - 3px); }
|
||||||
.task-mark { color: var(--color-muted); }
|
.task-mark { color: var(--fs-text-tertiary); }
|
||||||
.task-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.task-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.task-pri { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 6px; border-radius: 8px; border: 1px solid var(--color-border); color: var(--color-muted); }
|
.task-pri { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 6px; border-radius: 8px; border: 1px solid var(--fs-border-color); color: var(--fs-text-tertiary); }
|
||||||
.pri-high { color: #c0556b; border-color: #c0556b66; }
|
.pri-high { color: #c0556b; border-color: #c0556b66; }
|
||||||
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
|
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--fs-accent); 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; }
|
.rail-card { background: var(--fs-surface-hover); border: 1px solid var(--fs-border-color); 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 { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
|
||||||
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
|
.stats-sub { color: var(--fs-text-tertiary); font-size: 0.78rem; }
|
||||||
.proj-stats { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
|
.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 { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--fs-text-primary); font-size: 0.85rem; }
|
||||||
.pstat-row:hover { background: var(--color-hover); }
|
.pstat-row:hover { background: var(--fs-surface-hover); }
|
||||||
.pstat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
.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-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; }
|
.pstat-counts { font-size: 0.74rem; color: var(--fs-text-tertiary); white-space: nowrap; flex-shrink: 0; }
|
||||||
.quick-add { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
.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 { background: var(--fs-surface-hover); border: 1px solid var(--fs-border-color); border-radius: 8px; padding: 6px 12px; font-size: 0.82rem; color: var(--fs-text-primary); text-decoration: none; }
|
||||||
.qa-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
.qa-btn:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||||
|
|
||||||
.issues-card { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
|
.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 { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.4rem; border-radius: 7px; text-decoration: none; color: var(--fs-text-primary); font-size: 0.85rem; }
|
||||||
.issue-row:hover { background: var(--color-hover); }
|
.issue-row:hover { background: var(--fs-surface-hover); }
|
||||||
.issue-mark { color: var(--color-muted); flex-shrink: 0; }
|
.issue-mark { color: var(--fs-text-tertiary); flex-shrink: 0; }
|
||||||
.issue-mark.imk-in_progress { color: var(--color-primary); }
|
.issue-mark.imk-in_progress { color: var(--fs-accent); }
|
||||||
.issue-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.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; }
|
.issue-meta { font-size: 0.72rem; color: var(--fs-text-tertiary); white-space: nowrap; flex-shrink: 0; }
|
||||||
|
|
||||||
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
|
@media (max-width: 760px) { .dash-cols { flex-direction: column; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { apiPost } from "@/api/client";
|
import { apiPost, apiErrorMessage } from "@/api/client";
|
||||||
import AppLogo from "@/components/AppLogo.vue";
|
import AppLogo from "@/components/AppLogo.vue";
|
||||||
|
|
||||||
const email = ref("");
|
const email = ref("");
|
||||||
@@ -15,12 +15,7 @@ async function handleSubmit() {
|
|||||||
await apiPost("/api/auth/forgot-password", { email: email.value });
|
await apiPost("/api/auth/forgot-password", { email: email.value });
|
||||||
submitted.value = true;
|
submitted.value = true;
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e && typeof e === "object" && "body" in e) {
|
error.value = apiErrorMessage(e, "Something went wrong");
|
||||||
const body = (e as { body?: { error?: string } }).body;
|
|
||||||
error.value = body?.error || "Something went wrong";
|
|
||||||
} else {
|
|
||||||
error.value = "Something went wrong";
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
@@ -49,13 +44,13 @@ async function handleSubmit() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
<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" }}
|
{{ submitting ? "Sending..." : "Send Reset Link" }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else class="success-msg">
|
<div v-else class="auth-note">
|
||||||
<p>If an account exists with that email address, you will receive a password reset link shortly.</p>
|
<p>If an account exists with that email address, you will receive a password reset link shortly.</p>
|
||||||
<p>Check your email and follow the instructions to reset your password.</p>
|
<p>Check your email and follow the instructions to reset your password.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,101 +62,4 @@ async function handleSubmit() {
|
|||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style src="@/assets/auth-shared.css" />
|
||||||
.auth-page {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
.auth-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
.auth-brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.auth-card h1 {
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.auth-hint {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.field {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.field label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
background: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
.error-msg {
|
|
||||||
color: var(--color-danger);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
}
|
|
||||||
.success-msg {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
.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;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin: 1rem 0 0;
|
|
||||||
}
|
|
||||||
.auth-footer a {
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ function initGraph() {
|
|||||||
.attr("orient", "auto")
|
.attr("orient", "auto")
|
||||||
.append("path")
|
.append("path")
|
||||||
.attr("d", "M0,-4L8,0L0,4")
|
.attr("d", "M0,-4L8,0L0,4")
|
||||||
.attr("fill", "var(--color-primary)")
|
.attr("fill", "var(--fs-accent)")
|
||||||
.attr("opacity", "0.6");
|
.attr("opacity", "0.6");
|
||||||
|
|
||||||
// Zoom layer
|
// Zoom layer
|
||||||
@@ -237,7 +237,7 @@ function initGraph() {
|
|||||||
.append("line")
|
.append("line")
|
||||||
.attr("class", "graph-edge")
|
.attr("class", "graph-edge")
|
||||||
.attr("stroke", (d: any) =>
|
.attr("stroke", (d: any) =>
|
||||||
d.type === "wikilink" ? "var(--color-primary)" : "var(--color-text-muted)"
|
d.type === "wikilink" ? "var(--fs-accent)" : "var(--fs-text-tertiary)"
|
||||||
)
|
)
|
||||||
.attr("stroke-opacity", (d: any) => (d.type === "wikilink" ? 0.5 : 0.4))
|
.attr("stroke-opacity", (d: any) => (d.type === "wikilink" ? 0.5 : 0.4))
|
||||||
.attr("stroke-dasharray", null)
|
.attr("stroke-dasharray", null)
|
||||||
@@ -295,11 +295,11 @@ function initGraph() {
|
|||||||
.append("circle")
|
.append("circle")
|
||||||
.attr("r", (d: GraphNode) => d.radius ?? 8)
|
.attr("r", (d: GraphNode) => d.radius ?? 8)
|
||||||
.attr("fill", (d: GraphNode) => {
|
.attr("fill", (d: GraphNode) => {
|
||||||
if (d.type === "tag") return "color-mix(in srgb, var(--color-primary) 20%, transparent)";
|
if (d.type === "tag") return "color-mix(in srgb, var(--fs-accent) 20%, transparent)";
|
||||||
return d.project_color ?? "var(--color-bg-secondary)";
|
return d.project_color ?? "var(--fs-surface-raised)";
|
||||||
})
|
})
|
||||||
.attr("stroke", (d: GraphNode) =>
|
.attr("stroke", (d: GraphNode) =>
|
||||||
d.type === "tag" ? "var(--color-primary)" : "var(--color-border)"
|
d.type === "tag" ? "var(--fs-accent)" : "var(--fs-border-color)"
|
||||||
)
|
)
|
||||||
.attr("stroke-width", (d: GraphNode) => (d.type === "tag" ? 1.5 : 1.5))
|
.attr("stroke-width", (d: GraphNode) => (d.type === "tag" ? 1.5 : 1.5))
|
||||||
.attr("stroke-dasharray", (d: GraphNode) => (d.type === "task" ? "3" : null))
|
.attr("stroke-dasharray", (d: GraphNode) => (d.type === "task" ? "3" : null))
|
||||||
@@ -317,7 +317,7 @@ function initGraph() {
|
|||||||
.attr("dy", (d: GraphNode) => (d.radius ?? 8) + 12)
|
.attr("dy", (d: GraphNode) => (d.radius ?? 8) + 12)
|
||||||
.attr("font-size", "10px")
|
.attr("font-size", "10px")
|
||||||
.attr("fill", (d: GraphNode) =>
|
.attr("fill", (d: GraphNode) =>
|
||||||
d.type === "tag" ? "var(--color-primary)" : "var(--color-text-secondary)"
|
d.type === "tag" ? "var(--fs-accent)" : "var(--fs-text-secondary)"
|
||||||
)
|
)
|
||||||
.attr("pointer-events", "none");
|
.attr("pointer-events", "none");
|
||||||
|
|
||||||
@@ -600,7 +600,7 @@ onUnmounted(() => {
|
|||||||
.graph-page {
|
.graph-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - var(--header-height, 52px));
|
height: calc(100vh - var(--fs-layout-header));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,17 +609,17 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-select {
|
.graph-select {
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
padding: 0.3rem 0.6rem;
|
padding: 0.3rem 0.6rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -631,27 +631,27 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-toggle input {
|
.graph-toggle input {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
accent-color: var(--color-primary);
|
accent-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-stats {
|
.graph-stats {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-physics-btn {
|
.graph-physics-btn {
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
padding: 0.3rem 0.7rem;
|
padding: 0.3rem 0.7rem;
|
||||||
@@ -659,12 +659,12 @@ onUnmounted(() => {
|
|||||||
transition: border-color 0.15s, color 0.15s;
|
transition: border-color 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.graph-physics-btn:hover {
|
.graph-physics-btn:hover {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.graph-physics-btn.active {
|
.graph-physics-btn.active {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-physics-panel {
|
.graph-physics-panel {
|
||||||
@@ -672,8 +672,8 @@ onUnmounted(() => {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem 1.5rem;
|
gap: 0.5rem 1.5rem;
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,20 +688,20 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.knob-label {
|
.knob-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.knob-label em {
|
.knob-label em {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.physics-knob input[type="range"] {
|
.physics-knob input[type="range"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
accent-color: var(--color-primary);
|
accent-color: var(--fs-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -709,7 +709,7 @@ onUnmounted(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-svg {
|
.graph-svg {
|
||||||
@@ -728,15 +728,15 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.graph-empty p {
|
.graph-empty p {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border: 3px solid var(--color-border);
|
border: 3px solid var(--fs-border-color);
|
||||||
border-top-color: var(--color-primary);
|
border-top-color: var(--fs-accent);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.7s linear infinite;
|
animation: spin 0.7s linear infinite;
|
||||||
}
|
}
|
||||||
@@ -747,10 +747,10 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.graph-tooltip {
|
.graph-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--fs-radius-lg);
|
||||||
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;
|
padding: 0.5rem 0.75rem;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@@ -760,7 +760,7 @@ onUnmounted(() => {
|
|||||||
.tooltip-title {
|
.tooltip-title {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,15 +773,15 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.tag-chip {
|
.tag-chip {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0.1rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-meta {
|
.tooltip-meta {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -792,8 +792,8 @@ onUnmounted(() => {
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 340px;
|
width: 340px;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
border-left: 1px solid var(--color-border);
|
border-left: 1px solid var(--fs-border-color);
|
||||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
|
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -815,7 +815,7 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.6rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,10 +826,10 @@ onUnmounted(() => {
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
padding: 0.15rem 0.45rem;
|
padding: 0.15rem 0.45rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.peek-type-task { border-color: var(--color-primary); color: var(--color-primary); }
|
.peek-type-task { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||||
|
|
||||||
.peek-actions {
|
.peek-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -840,7 +840,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.peek-link {
|
.peek-link {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.peek-link:hover { text-decoration: underline; }
|
.peek-link:hover { text-decoration: underline; }
|
||||||
@@ -848,20 +848,20 @@ onUnmounted(() => {
|
|||||||
.peek-close {
|
.peek-close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.1rem 0.25rem;
|
padding: 0.1rem 0.25rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
.peek-close:hover { color: var(--color-text); }
|
.peek-close:hover { color: var(--fs-text-primary); }
|
||||||
|
|
||||||
.peek-title {
|
.peek-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.75rem 0.75rem 0.4rem;
|
padding: 0.75rem 0.75rem 0.4rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -877,7 +877,7 @@ onUnmounted(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0 0.75rem 0.5rem;
|
padding: 0 0.75rem 0.5rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.peek-prose {
|
.peek-prose {
|
||||||
@@ -887,7 +887,7 @@ onUnmounted(() => {
|
|||||||
.peek-loading,
|
.peek-loading,
|
||||||
.peek-empty {
|
.peek-empty {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -901,7 +901,7 @@ onUnmounted(() => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,14 +921,14 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.25rem 0.4rem;
|
padding: 0.25rem 0.4rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.peek-linked-item:hover {
|
.peek-linked-item:hover {
|
||||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card));
|
background: color-mix(in srgb, var(--fs-accent) 8%, var(--fs-surface-raised));
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.peek-linked-type {
|
.peek-linked-type {
|
||||||
@@ -937,12 +937,12 @@ onUnmounted(() => {
|
|||||||
width: 1.1rem;
|
width: 1.1rem;
|
||||||
height: 1.1rem;
|
height: 1.1rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { apiGet, apiPatch, listEvents } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
import { fmtCompact } from "@/utils/dateFormat";
|
|
||||||
import GraphView from "@/views/GraphView.vue";
|
import GraphView from "@/views/GraphView.vue";
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
User,
|
|
||||||
MapPin,
|
|
||||||
List,
|
|
||||||
Workflow,
|
Workflow,
|
||||||
Search,
|
Search,
|
||||||
Share2,
|
Share2,
|
||||||
@@ -24,28 +20,17 @@ const router = useRouter();
|
|||||||
|
|
||||||
interface KnowledgeItem {
|
interface KnowledgeItem {
|
||||||
id: number;
|
id: number;
|
||||||
note_type: "note" | "person" | "place" | "list" | "task" | "process";
|
note_type: "note" | "task" | "process";
|
||||||
title: string;
|
title: string;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
project_id: number | null;
|
project_id: number | null;
|
||||||
metadata: Record<string, string>;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
// type-specific
|
// Set only when another user owns this record — their suggestion, not one of
|
||||||
relationship?: string;
|
// yours. Absent means it's yours.
|
||||||
email?: string;
|
shared?: boolean;
|
||||||
phone?: string;
|
owner?: string | null;
|
||||||
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;
|
|
||||||
// Task-specific
|
// Task-specific
|
||||||
status?: string;
|
status?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
@@ -53,25 +38,62 @@ interface KnowledgeItem {
|
|||||||
task_kind?: "work" | "plan";
|
task_kind?: "work" | "plan";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpcomingEvent {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
start_dt: string;
|
|
||||||
all_day: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Filter state ─────────────────────────────────────────────────────────────
|
// ─── Filter state ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task" | "plan" | "process">("");
|
const activeType = ref<"" | "note" | "task" | "plan" | "process">("");
|
||||||
const activeTag = ref("");
|
const activeTag = ref("");
|
||||||
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// ─── Near-duplicate report ────────────────────────────────────────────────────
|
||||||
|
// On demand, never automatic — a corpus-wide pairwise scan, and most visits to
|
||||||
|
// this page aren't a tidy-up (same reasoning as SnippetListView's report).
|
||||||
|
|
||||||
|
interface DupMember {
|
||||||
|
id: number; title: string;
|
||||||
|
created_at?: string | null; updated_at?: string | null;
|
||||||
|
task_kind?: string | null;
|
||||||
|
}
|
||||||
|
interface DupGroup {
|
||||||
|
note_ids: number[];
|
||||||
|
members: DupMember[];
|
||||||
|
top_score: number;
|
||||||
|
existing_supersessions?: { superseder_id: number; superseded_id: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dupGroups = ref<DupGroup[]>([]);
|
||||||
|
const dupSuggestion = ref("");
|
||||||
|
const dupLoading = ref(false);
|
||||||
|
const dupChecked = ref(false);
|
||||||
|
// The report follows the type filter: viewing tasks checks tasks. Anything
|
||||||
|
// else (all / plan / process) checks notes — the kind with the most to find.
|
||||||
|
const dupKind = computed(() => (activeType.value === "task" ? "task" : "note"));
|
||||||
|
|
||||||
|
async function loadDuplicates() {
|
||||||
|
dupLoading.value = true;
|
||||||
|
try {
|
||||||
|
const data = await apiGet<{ groups: DupGroup[]; suggestion: string }>(
|
||||||
|
`/api/notes/duplicates?kind=${dupKind.value}`
|
||||||
|
);
|
||||||
|
dupGroups.value = data.groups;
|
||||||
|
dupSuggestion.value = data.suggestion;
|
||||||
|
dupChecked.value = true;
|
||||||
|
} catch {
|
||||||
|
dupChecked.value = false;
|
||||||
|
} finally {
|
||||||
|
dupLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stale report is worse than none: switching the type filter changes which
|
||||||
|
// kind the button checks, so the old kind's groups must not linger under it.
|
||||||
|
watch(dupKind, () => { dupChecked.value = false; dupGroups.value = []; });
|
||||||
|
|
||||||
// ─── Type counts ──────────────────────────────────────────────────────────────
|
// ─── Type counts ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface KnowledgeCounts { note: number; person: number; place: number; list: number; task: number; plan: number; process: number; total: number }
|
interface KnowledgeCounts { note: 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 });
|
const typeCounts = ref<KnowledgeCounts>({ note: 0, task: 0, plan: 0, process: 0, total: 0 });
|
||||||
|
|
||||||
async function fetchCounts() {
|
async function fetchCounts() {
|
||||||
try {
|
try {
|
||||||
@@ -212,26 +234,17 @@ watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
|
|||||||
|
|
||||||
// ─── Today bar ────────────────────────────────────────────────────────────────
|
// ─── Today bar ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const upcomingEvents = ref<UpcomingEvent[]>([]);
|
|
||||||
const overdueCount = ref(0);
|
const overdueCount = ref(0);
|
||||||
|
|
||||||
async function fetchTodayBar() {
|
async function fetchTodayBar() {
|
||||||
try {
|
try {
|
||||||
const now = new Date();
|
const taskData = await apiGet<{ total: number }>(
|
||||||
const end = new Date(now.getTime() + 7 * 86_400_000);
|
`/api/tasks?status=todo&status=in_progress&overdue=true&limit=1`
|
||||||
const [events, taskData] = await Promise.all([
|
).catch(() => ({ total: 0 }));
|
||||||
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);
|
|
||||||
overdueCount.value = taskData.total ?? 0;
|
overdueCount.value = taskData.total ?? 0;
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatEventDate = fmtCompact
|
|
||||||
|
|
||||||
// ─── Graph panel ──────────────────────────────────────────────────────────────
|
// ─── Graph panel ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const _GRAPH_OPEN_KEY = 'fa_knowledge_graph_open'
|
const _GRAPH_OPEN_KEY = 'fa_knowledge_graph_open'
|
||||||
@@ -250,40 +263,6 @@ function toggleGraphExpand() {
|
|||||||
localStorage.setItem(_GRAPH_EXP_KEY, String(graphExpanded.value))
|
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 ───────────────────────────────────────────────────────
|
// ─── Navigation helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function isOverdue(item: KnowledgeItem): boolean {
|
function isOverdue(item: KnowledgeItem): boolean {
|
||||||
@@ -352,22 +331,9 @@ onUnmounted(() => {
|
|||||||
<div class="knowledge-root" :class="{ 'graph-open': graphOpen, 'graph-expanded': graphExpanded && graphOpen }">
|
<div class="knowledge-root" :class="{ 'graph-open': graphOpen, 'graph-expanded': graphExpanded && graphOpen }">
|
||||||
|
|
||||||
<!-- Today bar -->
|
<!-- Today bar -->
|
||||||
<div class="today-bar">
|
<div v-if="overdueCount > 0" 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 class="today-actions">
|
<div class="today-actions">
|
||||||
<router-link v-if="overdueCount > 0" to="/tasks" class="overdue-badge">
|
<router-link to="/tasks" class="overdue-badge">
|
||||||
{{ overdueCount }} overdue
|
{{ overdueCount }} overdue
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
@@ -392,18 +358,6 @@ onUnmounted(() => {
|
|||||||
<CheckSquare :size="16" />
|
<CheckSquare :size="16" />
|
||||||
Task
|
Task
|
||||||
</button>
|
</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')">
|
<button @click="createNew('process')">
|
||||||
<Workflow :size="16" />
|
<Workflow :size="16" />
|
||||||
Process
|
Process
|
||||||
@@ -422,11 +376,11 @@ onUnmounted(() => {
|
|||||||
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
|
||||||
</button>
|
</button>
|
||||||
<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"
|
:key="val"
|
||||||
class="filter-btn"
|
class="filter-btn"
|
||||||
:class="{ active: activeType === val }"
|
: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 class="filter-btn-label">{{ label }}</span>
|
||||||
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
|
||||||
@@ -471,10 +425,55 @@ onUnmounted(() => {
|
|||||||
<option value="alpha">Alphabetical</option>
|
<option value="alpha">Alphabetical</option>
|
||||||
<option value="type">By type</option>
|
<option value="type">By type</option>
|
||||||
</select>
|
</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" />
|
<Share2 :size="16" />
|
||||||
Graph
|
Graph
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-ghost btn-compact"
|
||||||
|
:disabled="dupLoading"
|
||||||
|
title="Find notes or tasks already recorded that closely resemble each other — the report proposes, it never changes anything"
|
||||||
|
@click="loadDuplicates"
|
||||||
|
>
|
||||||
|
{{ dupLoading ? "Checking…" : "Find duplicates" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Near-duplicate report. A proposal surface only: unlike snippets
|
||||||
|
(which merge losslessly), notes are never merged — the right fix is
|
||||||
|
supersession, extraction into a reference note, or leaving parallel
|
||||||
|
records alone, and choosing needs the records READ. That reading is
|
||||||
|
the assistant's job; this panel shows the human what exists. -->
|
||||||
|
<div v-if="dupChecked && !dupLoading" class="dup-panel">
|
||||||
|
<p v-if="!dupGroups.length" class="dup-empty">
|
||||||
|
No near-duplicate {{ dupKind }}s found — nothing recorded resembles
|
||||||
|
anything else closely enough to flag.
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<p class="dup-head">
|
||||||
|
{{ dupGroups.length }} possible duplicate
|
||||||
|
{{ dupGroups.length > 1 ? "sets" : "set" }} among your
|
||||||
|
{{ dupKind }}s. {{ dupSuggestion }}
|
||||||
|
</p>
|
||||||
|
<div v-for="(g, i) in dupGroups" :key="i" class="dup-group">
|
||||||
|
<div class="dup-members">
|
||||||
|
<router-link
|
||||||
|
v-for="m in g.members"
|
||||||
|
:key="m.id"
|
||||||
|
class="dup-member"
|
||||||
|
:to="dupKind === 'task' ? `/tasks/${m.id}` : `/notes/${m.id}`"
|
||||||
|
>
|
||||||
|
#{{ m.id }} {{ m.title }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<span class="dup-score">{{ Math.round(g.top_score * 100) }}% alike</span>
|
||||||
|
<span
|
||||||
|
v-if="g.existing_supersessions?.length"
|
||||||
|
class="dup-claimed"
|
||||||
|
title="A supersession has already been declared inside this set — it is not an open question"
|
||||||
|
>already ruled on</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading / empty -->
|
<!-- Loading / empty -->
|
||||||
@@ -496,54 +495,15 @@ onUnmounted(() => {
|
|||||||
<!-- Type badge -->
|
<!-- Type badge -->
|
||||||
<span class="type-badge" :class="item.task_kind === 'plan' ? 'badge--plan' : `badge--${item.note_type}`">
|
<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-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 === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
|
||||||
<span v-else-if="item.note_type === 'process'">Process</span>
|
<span v-else-if="item.note_type === 'process'">Process</span>
|
||||||
<span v-else>List</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div class="k-card-body">
|
<div class="k-card-body">
|
||||||
<div class="k-card-title">{{ item.title }}</div>
|
<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 -->
|
<!-- 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">
|
<div class="task-badges">
|
||||||
<span class="status-badge" :class="`status--${item.status}`">
|
<span class="status-badge" :class="`status--${item.status}`">
|
||||||
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
|
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
|
||||||
@@ -570,6 +530,11 @@ onUnmounted(() => {
|
|||||||
<div class="k-card-tags">
|
<div class="k-card-tags">
|
||||||
<span v-for="tag in item.tags.slice(0, 3)" :key="tag" class="tag-pill">{{ tag }}</span>
|
<span v-for="tag in item.tags.slice(0, 3)" :key="tag" class="tag-pill">{{ tag }}</span>
|
||||||
</div>
|
</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>
|
<span class="k-card-date">{{ formatDate(item.updated_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -587,14 +552,14 @@ onUnmounted(() => {
|
|||||||
<span>Graph</span>
|
<span>Graph</span>
|
||||||
<div style="display:flex;gap:4px;align-items:center">
|
<div style="display:flex;gap:4px;align-items:center">
|
||||||
<button
|
<button
|
||||||
class="btn-icon-sm"
|
class="btn-text"
|
||||||
@click="toggleGraphExpand"
|
@click="toggleGraphExpand"
|
||||||
:title="graphExpanded ? 'Narrow panel' : 'Expand panel'"
|
:title="graphExpanded ? 'Narrow panel' : 'Expand panel'"
|
||||||
>
|
>
|
||||||
<ChevronLeft v-if="graphExpanded" :size="16" />
|
<ChevronLeft v-if="graphExpanded" :size="16" />
|
||||||
<ChevronRight v-else :size="16" />
|
<ChevronRight v-else :size="16" />
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-icon-sm" @click="toggleGraph" title="Close graph">
|
<button class="btn-text" @click="toggleGraph" title="Close graph">
|
||||||
<X :size="16" />
|
<X :size="16" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -614,7 +579,7 @@ onUnmounted(() => {
|
|||||||
.knowledge-root {
|
.knowledge-root {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: calc(100vh - var(--header-height, 56px));
|
height: calc(100vh - var(--fs-layout-header));
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,41 +590,12 @@ onUnmounted(() => {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
flex-wrap: wrap;
|
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; }
|
.today-actions { display: flex; align-items: center; gap: 10px; }
|
||||||
.overdue-badge {
|
.overdue-badge {
|
||||||
padding: 2px 9px;
|
padding: 2px 9px;
|
||||||
@@ -671,7 +607,7 @@ onUnmounted(() => {
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
.today-link {
|
.today-link {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
@@ -689,19 +625,19 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
/* ── Filter panel ────────────────────────────────────────── */
|
/* ── Filter panel ────────────────────────────────────────── */
|
||||||
.filter-panel {
|
.filter-panel {
|
||||||
width: var(--sidebar-width);
|
width: var(--fs-layout-sidebar);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 16px 12px;
|
padding: 16px 12px;
|
||||||
border-right: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
border-right: 1px solid var(--fs-border-color);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
}
|
}
|
||||||
.filter-section { margin-bottom: 20px; }
|
.filter-section { margin-bottom: 20px; }
|
||||||
.filter-section + .filter-section::before {
|
.filter-section + .filter-section::before {
|
||||||
content: '· · ·';
|
content: '· · ·';
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: rgba(91, 74, 138, 0.3);
|
color: color-mix(in srgb, var(--fs-accent) 30%, transparent);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
letter-spacing: 0.4em;
|
letter-spacing: 0.4em;
|
||||||
padding: 4px 0 12px;
|
padding: 4px 0 12px;
|
||||||
@@ -709,7 +645,7 @@ onUnmounted(() => {
|
|||||||
.filter-label {
|
.filter-label {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
@@ -726,14 +662,14 @@ onUnmounted(() => {
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--gradient-cta);
|
background: var(--fs-gradient-cta);
|
||||||
color: #fff;
|
color: var(--fs-text-on-action);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: box-shadow 0.15s;
|
transition: box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
.btn-new-note:hover { box-shadow: var(--glow-cta-hover); }
|
.btn-new-note:hover { box-shadow: var(--fs-glow-cta-hover); }
|
||||||
.btn-new-icon {
|
.btn-new-icon {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
@@ -744,8 +680,8 @@ onUnmounted(() => {
|
|||||||
top: calc(100% + 6px);
|
top: calc(100% + 6px);
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
@@ -760,15 +696,15 @@ onUnmounted(() => {
|
|||||||
padding: 9px 14px;
|
padding: 9px 14px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.84rem;
|
font-size: 0.84rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: background 0.12s, color 0.12s;
|
transition: background 0.12s, color 0.12s;
|
||||||
}
|
}
|
||||||
.new-note-menu button:hover {
|
.new-note-menu button:hover {
|
||||||
background: var(--color-primary-tint);
|
background: var(--fs-accent-soft);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.new-note-menu button svg {
|
.new-note-menu button svg {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -776,7 +712,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.new-note-menu button:hover svg {
|
.new-note-menu button:hover svg {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
stroke: var(--color-primary);
|
stroke: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-btn {
|
.filter-btn {
|
||||||
@@ -789,7 +725,7 @@ onUnmounted(() => {
|
|||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
@@ -798,8 +734,8 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.filter-btn:hover { background: rgba(255,255,255,0.05); opacity: 1; }
|
.filter-btn:hover { background: rgba(255,255,255,0.05); opacity: 1; }
|
||||||
.filter-btn.active {
|
.filter-btn.active {
|
||||||
background: var(--color-primary-wash);
|
background: var(--fs-accent-wash);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.filter-btn-label { flex: 1; }
|
.filter-btn-label { flex: 1; }
|
||||||
@@ -808,15 +744,15 @@ onUnmounted(() => {
|
|||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(255,255,255,0.07);
|
background: rgba(255,255,255,0.07);
|
||||||
color: var(--color-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.filter-btn.active .filter-count {
|
.filter-btn.active .filter-count {
|
||||||
background: rgba(91, 74, 138, 0.2);
|
background: color-mix(in srgb, var(--fs-accent) 20%, transparent);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.filter-tag { font-size: 0.78rem; }
|
.filter-tag { font-size: 0.78rem; }
|
||||||
|
|
||||||
@@ -836,7 +772,7 @@ onUnmounted(() => {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
.search-wrap {
|
.search-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -847,51 +783,31 @@ onUnmounted(() => {
|
|||||||
left: 10px;
|
left: 10px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
color: var(--color-muted);
|
color: var(--fs-text-tertiary);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.search-input {
|
.search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 7px 12px 7px 32px;
|
padding: 7px 12px 7px 32px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
|
border: 1px solid var(--fs-border-color);
|
||||||
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
|
background: var(--fs-surface-hover);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
.search-input:focus { border-color: var(--color-primary); }
|
.search-input:focus { border-color: var(--fs-accent); }
|
||||||
.sort-select {
|
.sort-select {
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--color-border, rgba(255,255,255,0.1));
|
border: 1px solid var(--fs-border-color);
|
||||||
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
|
background: var(--fs-surface-hover);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
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 ───────────────────────────────────────────── */
|
||||||
.card-grid {
|
.card-grid {
|
||||||
@@ -908,9 +824,9 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.k-card {
|
.k-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--color-surface, rgba(255,255,255,0.03));
|
background: var(--fs-surface-hover);
|
||||||
border: 1px solid var(--color-border, rgba(255,255,255,0.07));
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-lg, 14px);
|
border-radius: var(--fs-radius-xl);
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, transform 0.12s, box-shadow 0.15s;
|
transition: border-color 0.15s, transform 0.12s, box-shadow 0.15s;
|
||||||
@@ -922,21 +838,17 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.k-card:hover {
|
.k-card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 8px 28px rgba(91, 74, 138, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 28px color-mix(in srgb, var(--fs-accent) 25%, transparent), 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
border-color: rgba(91, 74, 138, 0.35);
|
border-color: color-mix(in srgb, var(--fs-accent) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Type-specific card DNA */
|
/* 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(--fs-accent) 20%, transparent); }
|
||||||
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
|
.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 */
|
/* Top gradient bars */
|
||||||
.k-card--note::before,
|
.k-card--note::before,
|
||||||
.k-card--task::before,
|
.k-card--task::before {
|
||||||
.k-card--list::before {
|
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -946,31 +858,12 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.k-card--note::before {
|
.k-card--note::before {
|
||||||
right: 0;
|
right: 0;
|
||||||
background: linear-gradient(90deg, #5B4A8A, #7A6DA8);
|
background: linear-gradient(90deg, var(--fs-accent), #7A6DA8);
|
||||||
}
|
}
|
||||||
.k-card--task::before {
|
.k-card--task::before {
|
||||||
right: 0;
|
right: 0;
|
||||||
background: linear-gradient(90deg, #d4a017, #fbbf24);
|
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 */
|
||||||
.type-badge {
|
.type-badge {
|
||||||
@@ -984,10 +877,7 @@ onUnmounted(() => {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
.badge--note { background: rgba(91, 74, 138,0.15); color: #7A6DA8; }
|
.badge--note { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); 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--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
|
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
|
||||||
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
|
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
|
||||||
|
|
||||||
@@ -1004,7 +894,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.k-card-snippet {
|
.k-card-snippet {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-muted);
|
color: var(--fs-text-tertiary);
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 4;
|
-webkit-line-clamp: 4;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
@@ -1012,70 +902,6 @@ onUnmounted(() => {
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
margin: 0;
|
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 {
|
.k-card-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1088,9 +914,19 @@ onUnmounted(() => {
|
|||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255,255,255,0.05);
|
background: rgba(255,255,255,0.05);
|
||||||
color: var(--color-muted);
|
color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
.k-card-date { font-size: 0.72rem; color: var(--fs-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(--fs-text-secondary) 15%, transparent);
|
||||||
|
color: var(--fs-text-secondary);
|
||||||
}
|
}
|
||||||
.k-card-date { font-size: 0.72rem; color: var(--color-text-secondary); white-space: nowrap; opacity: 0.7; }
|
|
||||||
|
|
||||||
/* ── Task card ──────────────────────────────────────────── */
|
/* ── Task card ──────────────────────────────────────────── */
|
||||||
.k-card-task {
|
.k-card-task {
|
||||||
@@ -1109,10 +945,10 @@ onUnmounted(() => {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.status--todo { background: var(--color-status-todo-bg); color: var(--color-status-todo); }
|
.status--todo { background: var(--fs-status-todo-bg); color: var(--fs-status-todo); }
|
||||||
.status--in_progress { background: var(--color-status-in-progress-bg); color: var(--color-status-in-progress); }
|
.status--in_progress { background: var(--fs-status-in-progress-bg); color: var(--fs-status-in-progress); }
|
||||||
.status--done { background: var(--color-status-done-bg); color: var(--color-status-done); }
|
.status--done { background: var(--fs-status-done-bg); color: var(--fs-status-done); }
|
||||||
.status--cancelled { background: var(--color-status-todo-bg); color: var(--color-status-todo); text-decoration: line-through; }
|
.status--cancelled { background: var(--fs-status-todo-bg); color: var(--fs-status-todo); text-decoration: line-through; }
|
||||||
|
|
||||||
.priority-badge {
|
.priority-badge {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
@@ -1120,16 +956,16 @@ onUnmounted(() => {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.priority--low { background: var(--color-priority-low-bg); color: var(--color-priority-low); }
|
.priority--low { background: var(--fs-priority-low-bg); color: var(--fs-priority-low); }
|
||||||
.priority--normal { background: var(--color-priority-medium-bg); color: var(--color-priority-medium); }
|
.priority--normal { background: var(--fs-priority-medium-bg); color: var(--fs-priority-medium); }
|
||||||
.priority--high { background: var(--color-priority-high-bg); color: var(--color-priority-high); }
|
.priority--high { background: var(--fs-priority-high-bg); color: var(--fs-priority-high); }
|
||||||
|
|
||||||
.task-due {
|
.task-due {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
}
|
}
|
||||||
.task-overdue {
|
.task-overdue {
|
||||||
color: var(--color-overdue);
|
color: var(--fs-overdue);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1141,7 +977,7 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
color: var(--color-muted);
|
color: var(--fs-text-tertiary);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
@@ -1149,7 +985,7 @@ onUnmounted(() => {
|
|||||||
.empty-narrator {
|
.empty-narrator {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1163,17 +999,17 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.sentinel-loading {
|
.sentinel-loading {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Graph panel ─────────────────────────────────────────── */
|
/* ── Graph panel ─────────────────────────────────────────── */
|
||||||
.graph-panel {
|
.graph-panel {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-left: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
border-left: 1px solid var(--fs-border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
transition: width 0.2s ease;
|
transition: width 0.2s ease;
|
||||||
}
|
}
|
||||||
.graph-panel.expanded {
|
.graph-panel.expanded {
|
||||||
@@ -1186,28 +1022,83 @@ onUnmounted(() => {
|
|||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.btn-icon-sm {
|
/* RESTORED (#2444). The panel is a flex COLUMN and its header is
|
||||||
background: none;
|
`flex-shrink: 0`, so this is the item that takes the remaining height — and
|
||||||
border: none;
|
without it the `height: 100%` below resolves against `auto` and does
|
||||||
color: var(--color-muted);
|
nothing, which made the comment underneath a spec for a rule that could not
|
||||||
cursor: pointer;
|
work. `min-height: 0` is the companion that lets a flex item shrink under
|
||||||
padding: 2px 6px;
|
its content instead of overflowing the panel. */
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
transition: color 0.15s;
|
|
||||||
}
|
|
||||||
.btn-icon-sm:hover { color: var(--color-text); }
|
|
||||||
.graph-embed {
|
.graph-embed {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
min-height: 0;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Override GraphView's 100vh height so it fills the panel instead */
|
/* Override GraphView's 100vh height so it fills the panel instead */
|
||||||
.graph-embed :deep(.graph-page) {
|
.graph-embed :deep(.graph-page) {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Near-duplicate report ──────────────────────────────────────────────────
|
||||||
|
Mirrors SnippetListView's panel so the two reports read as one feature.
|
||||||
|
Scoped styles can't be shared across SFCs; if a third view ever grows this
|
||||||
|
panel, promote the family to components.css and record it (#2464's rule:
|
||||||
|
two-or-more is when a recipe earns the shared sheet). */
|
||||||
|
.dup-panel {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--fs-surface-hover);
|
||||||
|
}
|
||||||
|
.dup-empty,
|
||||||
|
.dup-head {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
.dup-empty { margin-bottom: 0; }
|
||||||
|
.dup-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-top: 1px solid var(--fs-border-color);
|
||||||
|
}
|
||||||
|
.dup-members {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1 1 20rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.dup-member {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: color-mix(in srgb, var(--fs-text-tertiary) 12%, transparent);
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.dup-member:hover { background: var(--fs-surface-hover); }
|
||||||
|
.dup-score {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* A set someone already ruled on — quiet, not celebratory: it means "skip". */
|
||||||
|
.dup-claimed {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.05rem 0.4rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
|
|||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import AppLogo from "@/components/AppLogo.vue";
|
import AppLogo from "@/components/AppLogo.vue";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -30,12 +31,7 @@ async function handleSubmit() {
|
|||||||
const redirect = (route.query.redirect as string) || "/";
|
const redirect = (route.query.redirect as string) || "/";
|
||||||
router.push(redirect);
|
router.push(redirect);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e && typeof e === "object" && "body" in e) {
|
error.value = apiErrorMessage(e, "Login failed");
|
||||||
const body = (e as { body?: { error?: string } }).body;
|
|
||||||
error.value = body?.error || "Login failed";
|
|
||||||
} else {
|
|
||||||
error.value = "Login failed";
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
@@ -84,7 +80,7 @@ function loginWithOAuth() {
|
|||||||
<p class="forgot-link">
|
<p class="forgot-link">
|
||||||
<router-link to="/forgot-password">Forgot your password?</router-link>
|
<router-link to="/forgot-password">Forgot your password?</router-link>
|
||||||
</p>
|
</p>
|
||||||
<button type="submit" class="btn-submit" :disabled="submitting">
|
<button type="submit" class="btn-primary btn-block" :disabled="submitting">
|
||||||
{{ submitting ? "Signing in..." : "Sign In" }}
|
{{ submitting ? "Signing in..." : "Sign In" }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -98,7 +94,7 @@ function loginWithOAuth() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="authStore.oauthEnabled"
|
v-if="authStore.oauthEnabled"
|
||||||
class="btn-oauth"
|
class="btn-ghost btn-block"
|
||||||
@click="loginWithOAuth"
|
@click="loginWithOAuth"
|
||||||
>
|
>
|
||||||
Login with Authentik
|
Login with Authentik
|
||||||
@@ -112,124 +108,21 @@ function loginWithOAuth() {
|
|||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style src="@/assets/auth-shared.css" />
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.auth-page {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
.auth-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
.auth-brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.auth-card h1 {
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.auth-hint {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.auth-hint a {
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
.field {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.field label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
background: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
.error-msg {
|
|
||||||
color: var(--color-danger);
|
|
||||||
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 {
|
.divider {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin: 1.25rem 0;
|
margin: 1.25rem 0;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.divider::before,
|
.divider::before,
|
||||||
.divider::after {
|
.divider::after {
|
||||||
content: "";
|
content: "";
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--fs-border-color);
|
||||||
}
|
|
||||||
.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;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin: 1rem 0 0;
|
|
||||||
}
|
|
||||||
.auth-footer a {
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
}
|
||||||
.forgot-link {
|
.forgot-link {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -237,9 +130,9 @@ function loginWithOAuth() {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.forgot-link a {
|
.forgot-link a {
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
}
|
}
|
||||||
.forgot-link a:hover {
|
.forgot-link a:hover {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, onMounted, watch } from "vue";
|
|||||||
import { apiGet } from "@/api/client";
|
import { apiGet } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import PaginationBar from "@/components/PaginationBar.vue";
|
import PaginationBar from "@/components/PaginationBar.vue";
|
||||||
|
import { fmtLogStamp } from "@/utils/dateFormat";
|
||||||
|
|
||||||
const toastStore = useToastStore();
|
const toastStore = useToastStore();
|
||||||
|
|
||||||
@@ -98,17 +99,6 @@ function toggleExpand(id: number) {
|
|||||||
expandedId.value = expandedId.value === id ? null : id;
|
expandedId.value = expandedId.value === id ? null : id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleString(undefined, {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDetails(details: string | null): string {
|
function formatDetails(details: string | null): string {
|
||||||
if (!details) return "";
|
if (!details) return "";
|
||||||
try {
|
try {
|
||||||
@@ -177,7 +167,7 @@ function clearFilters() {
|
|||||||
<input v-model="dateTo" type="date" class="filter-date" title="To date" />
|
<input v-model="dateTo" type="date" class="filter-date" title="To date" />
|
||||||
<button
|
<button
|
||||||
v-if="category || search || dateFrom || dateTo"
|
v-if="category || search || dateFrom || dateTo"
|
||||||
class="btn-clear"
|
class="btn-ghost btn-compact"
|
||||||
@click="clearFilters"
|
@click="clearFilters"
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
@@ -210,7 +200,7 @@ function clearFilters() {
|
|||||||
:class="{ 'row-expanded': expandedId === entry.id }"
|
:class="{ 'row-expanded': expandedId === entry.id }"
|
||||||
@click="toggleExpand(entry.id)"
|
@click="toggleExpand(entry.id)"
|
||||||
>
|
>
|
||||||
<td class="cell-time">{{ formatTime(entry.created_at) }}</td>
|
<td class="cell-time">{{ fmtLogStamp(entry.created_at) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="category-badge" :class="'cat-' + entry.category">
|
<span class="category-badge" :class="'cat-' + entry.category">
|
||||||
{{ entry.category }}
|
{{ entry.category }}
|
||||||
@@ -263,9 +253,9 @@ function clearFilters() {
|
|||||||
margin: 0 0 1.5rem;
|
margin: 0 0 1.5rem;
|
||||||
}
|
}
|
||||||
.settings-section {
|
.settings-section {
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--fs-radius-lg);
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -292,23 +282,23 @@ function clearFilters() {
|
|||||||
.stat-count {
|
.stat-count {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.stat-audit {
|
.stat-audit {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.stat-usage {
|
.stat-usage {
|
||||||
color: var(--color-success);
|
color: var(--fs-success);
|
||||||
}
|
}
|
||||||
.stat-error {
|
.stat-error {
|
||||||
color: var(--color-danger);
|
color: var(--fs-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Filters */
|
/* Filters */
|
||||||
@@ -321,10 +311,10 @@ function clearFilters() {
|
|||||||
.filter-input,
|
.filter-input,
|
||||||
.filter-date {
|
.filter-date {
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.filter-select {
|
.filter-select {
|
||||||
@@ -337,25 +327,12 @@ function clearFilters() {
|
|||||||
.filter-date {
|
.filter-date {
|
||||||
width: 140px;
|
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 */
|
/* Table */
|
||||||
.loading-msg,
|
.loading-msg,
|
||||||
.empty-msg {
|
.empty-msg {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
}
|
}
|
||||||
@@ -369,13 +346,13 @@ function clearFilters() {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
.logs-table td {
|
.logs-table td {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.logs-table tbody tr:last-child td {
|
.logs-table tbody tr:last-child td {
|
||||||
@@ -386,18 +363,18 @@ function clearFilters() {
|
|||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
.log-row:hover {
|
.log-row:hover {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
}
|
}
|
||||||
.row-expanded {
|
.row-expanded {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
}
|
}
|
||||||
.cell-time {
|
.cell-time {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
.cell-user {
|
.cell-user {
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
}
|
}
|
||||||
.cell-action {
|
.cell-action {
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
@@ -412,22 +389,22 @@ function clearFilters() {
|
|||||||
.cell-ip {
|
.cell-ip {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.cell-duration {
|
.cell-duration {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.detail-ip {
|
.detail-ip {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
.text-error {
|
.text-error {
|
||||||
color: var(--color-danger);
|
color: var(--fs-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Category badges */
|
/* Category badges */
|
||||||
@@ -438,19 +415,19 @@ function clearFilters() {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
padding: 0.1rem 0.35rem;
|
padding: 0.1rem 0.35rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
}
|
}
|
||||||
.cat-audit {
|
.cat-audit {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||||
}
|
}
|
||||||
.cat-usage {
|
.cat-usage {
|
||||||
color: var(--color-success);
|
color: var(--fs-success);
|
||||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
|
||||||
}
|
}
|
||||||
.cat-error {
|
.cat-error {
|
||||||
color: var(--color-danger);
|
color: var(--fs-error);
|
||||||
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Method tag */
|
/* Method tag */
|
||||||
@@ -461,22 +438,24 @@ function clearFilters() {
|
|||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
padding: 0.05rem 0.25rem;
|
padding: 0.05rem 0.25rem;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Detail row */
|
/* Detail row */
|
||||||
|
/* `.detail-row` is deliberately bare: a `<tr>` has nothing to style that its
|
||||||
|
cells don't carry, and the row exists to scope the rule below (#2444). */
|
||||||
.detail-row td {
|
.detail-row td {
|
||||||
padding: 0 0.75rem 0.75rem;
|
padding: 0 0.75rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
.detail-json {
|
.detail-json {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from "vue-router";
|
||||||
import type { NoteType } from "@/types/note";
|
import type { NoteType } from "@/types/note";
|
||||||
import { useNotesStore } from "@/stores/notes";
|
import { useNotesStore } from "@/stores/notes";
|
||||||
@@ -34,85 +34,10 @@ const tags = ref<string[]>([]);
|
|||||||
const projectId = ref<number | null>(null);
|
const projectId = ref<number | null>(null);
|
||||||
const milestoneId = ref<number | null>(null);
|
const milestoneId = ref<number | null>(null);
|
||||||
const noteType = ref<NoteType>("note");
|
const noteType = ref<NoteType>("note");
|
||||||
const entityMeta = reactive<Record<string, string>>({});
|
|
||||||
const dirty = ref(false);
|
const dirty = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const showPreview = ref(false);
|
const showPreview = ref(false);
|
||||||
const sidebarOpen = ref(true);
|
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 editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
|
||||||
const titleRef = ref<HTMLInputElement | null>(null);
|
const titleRef = ref<HTMLInputElement | null>(null);
|
||||||
@@ -127,9 +52,6 @@ const isEditing = computed(() => noteId.value !== null);
|
|||||||
|
|
||||||
const titlePlaceholder = computed(() => {
|
const titlePlaceholder = computed(() => {
|
||||||
switch (noteType.value) {
|
switch (noteType.value) {
|
||||||
case 'person': return 'Name';
|
|
||||||
case 'place': return 'Place name';
|
|
||||||
case 'list': return 'List title';
|
|
||||||
case 'process': return 'Process name';
|
case 'process': return 'Process name';
|
||||||
default: return 'Title';
|
default: return 'Title';
|
||||||
}
|
}
|
||||||
@@ -276,7 +198,6 @@ let savedTags: string[] = [];
|
|||||||
let savedProjectId: number | null = null;
|
let savedProjectId: number | null = null;
|
||||||
let savedMilestoneId: number | null = null;
|
let savedMilestoneId: number | null = null;
|
||||||
let savedNoteType: NoteType = "note";
|
let savedNoteType: NoteType = "note";
|
||||||
let savedEntityMeta: Record<string, string> = {};
|
|
||||||
|
|
||||||
function markDirty() {
|
function markDirty() {
|
||||||
dirty.value =
|
dirty.value =
|
||||||
@@ -285,8 +206,7 @@ function markDirty() {
|
|||||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||||
projectId.value !== savedProjectId ||
|
projectId.value !== savedProjectId ||
|
||||||
milestoneId.value !== savedMilestoneId ||
|
milestoneId.value !== savedMilestoneId ||
|
||||||
noteType.value !== savedNoteType ||
|
noteType.value !== savedNoteType;
|
||||||
JSON.stringify(entityMeta) !== JSON.stringify(savedEntityMeta);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onBodyUpdate(newVal: string) {
|
function onBodyUpdate(newVal: string) {
|
||||||
@@ -304,31 +224,19 @@ onMounted(async () => {
|
|||||||
projectId.value = store.currentNote.project_id ?? null;
|
projectId.value = store.currentNote.project_id ?? null;
|
||||||
milestoneId.value = store.currentNote.milestone_id ?? null;
|
milestoneId.value = store.currentNote.milestone_id ?? null;
|
||||||
noteType.value = (store.currentNote.note_type as NoteType) || "note";
|
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;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
savedTags = [...tags.value];
|
savedTags = [...tags.value];
|
||||||
savedProjectId = projectId.value;
|
savedProjectId = projectId.value;
|
||||||
savedMilestoneId = milestoneId.value;
|
savedMilestoneId = milestoneId.value;
|
||||||
savedNoteType = noteType.value;
|
savedNoteType = noteType.value;
|
||||||
savedEntityMeta = { ...entityMeta };
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// New note: read type from query param
|
// New note: read type from query param
|
||||||
const qt = route.query.type as string | undefined;
|
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;
|
noteType.value = qt as NoteType;
|
||||||
}
|
}
|
||||||
if (noteType.value === 'list') {
|
|
||||||
listItems.value = [{ text: '', checked: false }];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore pending draft if any
|
// Restore pending draft if any
|
||||||
@@ -349,7 +257,7 @@ onMounted(async () => {
|
|||||||
async function save() {
|
async function save() {
|
||||||
if (saving.value) return;
|
if (saving.value) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
const finalBody = body.value;
|
||||||
try {
|
try {
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
await store.updateNote(noteId.value!, {
|
await store.updateNote(noteId.value!, {
|
||||||
@@ -359,7 +267,6 @@ async function save() {
|
|||||||
project_id: projectId.value,
|
project_id: projectId.value,
|
||||||
milestone_id: milestoneId.value,
|
milestone_id: milestoneId.value,
|
||||||
note_type: noteType.value,
|
note_type: noteType.value,
|
||||||
metadata: { ...entityMeta },
|
|
||||||
});
|
});
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
@@ -367,7 +274,6 @@ async function save() {
|
|||||||
savedProjectId = projectId.value;
|
savedProjectId = projectId.value;
|
||||||
savedMilestoneId = milestoneId.value;
|
savedMilestoneId = milestoneId.value;
|
||||||
savedNoteType = noteType.value;
|
savedNoteType = noteType.value;
|
||||||
savedEntityMeta = { ...entityMeta };
|
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Note saved");
|
toast.show("Note saved");
|
||||||
} else {
|
} else {
|
||||||
@@ -378,7 +284,6 @@ async function save() {
|
|||||||
project_id: projectId.value,
|
project_id: projectId.value,
|
||||||
milestone_id: milestoneId.value,
|
milestone_id: milestoneId.value,
|
||||||
note_type: noteType.value,
|
note_type: noteType.value,
|
||||||
metadata: { ...entityMeta },
|
|
||||||
});
|
});
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Note created");
|
toast.show("Note created");
|
||||||
@@ -414,12 +319,12 @@ async function confirmDelete() {
|
|||||||
async function doAutoSave() {
|
async function doAutoSave() {
|
||||||
if (!isEditing.value || saving.value) return;
|
if (!isEditing.value || saving.value) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
|
const finalBody = body.value;
|
||||||
try {
|
try {
|
||||||
await store.updateNote(noteId.value!, {
|
await store.updateNote(noteId.value!, {
|
||||||
title: title.value, body: finalBody, tags: tags.value,
|
title: title.value, body: finalBody, tags: tags.value,
|
||||||
project_id: projectId.value, milestone_id: milestoneId.value,
|
project_id: projectId.value, milestone_id: milestoneId.value,
|
||||||
note_type: noteType.value, metadata: { ...entityMeta },
|
note_type: noteType.value,
|
||||||
});
|
});
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
@@ -427,7 +332,6 @@ async function doAutoSave() {
|
|||||||
savedProjectId = projectId.value;
|
savedProjectId = projectId.value;
|
||||||
savedMilestoneId = milestoneId.value;
|
savedMilestoneId = milestoneId.value;
|
||||||
savedNoteType = noteType.value;
|
savedNoteType = noteType.value;
|
||||||
savedEntityMeta = { ...entityMeta };
|
|
||||||
dirty.value = false;
|
dirty.value = false;
|
||||||
toast.show("Auto-saved");
|
toast.show("Auto-saved");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -473,138 +377,8 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
|
|
||||||
<!-- ── Main column ────────────────────────────────────────── -->
|
<!-- ── Main column ────────────────────────────────────────── -->
|
||||||
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
|
<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 ──────────────────────────── -->
|
<!-- ── Process (prompt) editor ──────────────────────────── -->
|
||||||
<template v-else-if="noteType === 'process'">
|
<template v-if="noteType === 'process'">
|
||||||
<label class="ef-label">Prompt</label>
|
<label class="ef-label">Prompt</label>
|
||||||
<textarea
|
<textarea
|
||||||
class="prompt-editor"
|
class="prompt-editor"
|
||||||
@@ -718,9 +492,7 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
<label class="sb-label">Type</label>
|
<label class="sb-label">Type</label>
|
||||||
<select v-model="noteType" class="sb-select" @change="markDirty">
|
<select v-model="noteType" class="sb-select" @change="markDirty">
|
||||||
<option value="note">Note</option>
|
<option value="note">Note</option>
|
||||||
<option value="person">Person</option>
|
<option value="process">Process</option>
|
||||||
<option value="place">Place</option>
|
|
||||||
<option value="list">List</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -861,13 +633,13 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding-bottom: 0.5rem;
|
padding-bottom: 0.5rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-tabs {
|
.editor-tabs {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -881,14 +653,14 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
padding: 0.22rem 0.75rem;
|
padding: 0.22rem 0.75rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.tab:hover { color: var(--color-text); }
|
.tab:hover { color: var(--fs-text-primary); }
|
||||||
.tab.active {
|
.tab.active {
|
||||||
background: var(--color-surface);
|
background: var(--fs-surface-hover);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -907,14 +679,14 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
|
|
||||||
.stream-label {
|
.stream-label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stream-preview {
|
.stream-preview {
|
||||||
border: 1px solid var(--color-input-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,7 +699,7 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
.note-sidebar {
|
.note-sidebar {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-left: 1px solid var(--color-border);
|
border-left: 1px solid var(--fs-border-color);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -936,17 +708,17 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
.sb-select, .sb-input {
|
.sb-select, .sb-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
border: 1px solid var(--color-input-border, rgba(255,255,255,0.12));
|
border: 1px solid var(--fs-border-color);
|
||||||
background: var(--color-bg-tertiary, rgba(255,255,255,0.04));
|
background: var(--fs-surface-hover);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
.sb-select:focus, .sb-input:focus {
|
.sb-select:focus, .sb-input:focus {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tag suggest row inside sidebar */
|
/* Tag suggest row inside sidebar */
|
||||||
@@ -970,13 +742,13 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-primary);
|
border: 1px solid var(--fs-accent);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.btn-link-all:hover { background: var(--color-primary); color: #fff; }
|
.btn-link-all:hover { background: var(--fs-action-primary); color: var(--fs-text-on-action); }
|
||||||
|
|
||||||
.link-suggest-list {
|
.link-suggest-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -994,14 +766,14 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
.link-suggest-title {
|
.link-suggest-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-suggest-count {
|
.link-suggest-count {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -1011,13 +783,13 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding: 0.1rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
}
|
}
|
||||||
.btn-apply-link:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
.btn-apply-link:hover { border-color: var(--fs-accent); color: var(--fs-accent); }
|
||||||
|
|
||||||
/* Writing Assistant section */
|
/* Writing Assistant section */
|
||||||
.assist-section {
|
.assist-section {
|
||||||
@@ -1029,39 +801,28 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
.assist-section-title {
|
.assist-section-title {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Entity form (Person / Place) ───────────────────────── */
|
/* ── Process editor ─────────────────────────────────────── */
|
||||||
.entity-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px 0;
|
|
||||||
}
|
|
||||||
.ef-field {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.ef-label {
|
.ef-label {
|
||||||
font-family: 'Fraunces', Georgia, serif;
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.prompt-editor {
|
.prompt-editor {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--color-surface);
|
background: var(--fs-surface-hover);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
/* Prompts are plain markdown — a code-style editor, not rich 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(--fs-font-mono);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
tab-size: 2;
|
tab-size: 2;
|
||||||
@@ -1069,121 +830,8 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.prompt-editor:focus {
|
.prompt-editor:focus {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.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 */
|
/* Narrow screen: sidebar collapses */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
||||||
@@ -1191,7 +839,7 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
.note-sidebar {
|
.note-sidebar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--fs-border-color);
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,22 +194,22 @@ async function convertToTask() {
|
|||||||
</div>
|
</div>
|
||||||
<template v-else-if="store.currentNote">
|
<template v-else-if="store.currentNote">
|
||||||
<div class="toolbar">
|
<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
|
<router-link
|
||||||
:to="`/notes/${store.currentNote.id}/edit`"
|
:to="`/notes/${store.currentNote.id}/edit`"
|
||||||
class="btn-edit"
|
class="btn-primary"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</router-link>
|
</router-link>
|
||||||
<button
|
<button
|
||||||
v-if="!store.currentNote.is_task"
|
v-if="!store.currentNote.is_task"
|
||||||
class="btn-convert"
|
class="btn-secondary btn-compact"
|
||||||
@click="convertToTask"
|
@click="convertToTask"
|
||||||
:disabled="converting"
|
:disabled="converting"
|
||||||
>
|
>
|
||||||
{{ converting ? "Converting..." : "Convert to Task" }}
|
{{ converting ? "Converting..." : "Convert to Task" }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-share" @click="showShare = true">Share</button>
|
<button class="btn-secondary btn-compact" @click="showShare = true">Share</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Breadcrumb: parent → project → milestone -->
|
<!-- Breadcrumb: parent → project → milestone -->
|
||||||
@@ -329,81 +329,10 @@ async function convertToTask() {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 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
|
/* Edit: Moss action-primary — switching from view to edit is operating
|
||||||
the software, not a brand moment. */
|
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 */
|
/* 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 {
|
.meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -411,7 +340,7 @@ async function convertToTask() {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
font-size: 0.83rem;
|
font-size: 0.83rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
}
|
}
|
||||||
.meta-item {
|
.meta-item {
|
||||||
@@ -430,7 +359,7 @@ async function convertToTask() {
|
|||||||
}
|
}
|
||||||
.backlinks {
|
.backlinks {
|
||||||
margin-top: 2.5rem;
|
margin-top: 2.5rem;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--fs-border-color);
|
||||||
padding-top: 1.25rem;
|
padding-top: 1.25rem;
|
||||||
}
|
}
|
||||||
.backlinks-heading {
|
.backlinks-heading {
|
||||||
@@ -441,14 +370,14 @@ async function convertToTask() {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
}
|
}
|
||||||
.backlinks-count {
|
.backlinks-count {
|
||||||
margin-left: 0.2rem;
|
margin-left: 0.2rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 0 0.4rem;
|
padding: 0 0.4rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -463,18 +392,18 @@ async function convertToTask() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--fs-radius-lg);
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
transition: border-color 0.15s, box-shadow 0.15s;
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.backlink-card:hover {
|
.backlink-card:hover {
|
||||||
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
border-color: color-mix(in srgb, var(--fs-accent) 50%, transparent);
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.backlink-type-badge {
|
.backlink-type-badge {
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
@@ -486,9 +415,9 @@ async function convertToTask() {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.badge-note {
|
.badge-note {
|
||||||
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 12%, transparent);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
border: 1px solid color-mix(in srgb, var(--fs-accent) 25%, transparent);
|
||||||
}
|
}
|
||||||
.badge-task {
|
.badge-task {
|
||||||
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
background: color-mix(in srgb, #f59e0b 12%, transparent);
|
||||||
@@ -517,12 +446,12 @@ async function convertToTask() {
|
|||||||
.skel-title,
|
.skel-title,
|
||||||
.skel-meta,
|
.skel-meta,
|
||||||
.skel-line {
|
.skel-line {
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
var(--color-bg-secondary) 25%,
|
var(--fs-surface-raised) 25%,
|
||||||
color-mix(in srgb, var(--color-text-muted) 18%, var(--color-bg-secondary)) 50%,
|
color-mix(in srgb, var(--fs-text-tertiary) 18%, var(--fs-surface-raised)) 50%,
|
||||||
var(--color-bg-secondary) 75%
|
var(--fs-surface-raised) 75%
|
||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: skel-shine 1.5s ease infinite;
|
animation: skel-shine 1.5s ease infinite;
|
||||||
@@ -534,7 +463,7 @@ async function convertToTask() {
|
|||||||
}
|
}
|
||||||
.skel-btn { width: 70px; height: 32px; }
|
.skel-btn { width: 70px; height: 32px; }
|
||||||
.skel-btn--wide { width: 90px; }
|
.skel-btn--wide { width: 90px; }
|
||||||
.skel-title { height: 2.2rem; width: 70%; border-radius: var(--radius-md); }
|
.skel-title { height: 2.2rem; width: 70%; border-radius: var(--fs-radius-lg); }
|
||||||
.skel-meta { height: 0.85rem; width: 40%; }
|
.skel-meta { height: 0.85rem; width: 40%; }
|
||||||
.skel-line { height: 0.9rem; }
|
.skel-line { height: 0.9rem; }
|
||||||
.skel-line--short { width: 55%; }
|
.skel-line--short { width: 55%; }
|
||||||
|
|||||||
@@ -113,6 +113,45 @@ function truncate(text: string | null, max = 120): string {
|
|||||||
return text.length > max ? text.slice(0, max) + "..." : text;
|
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 } {
|
function overallPct(project: Project): { total: number; pct: number } {
|
||||||
const counts = project.summary?.task_counts;
|
const counts = project.summary?.task_counts;
|
||||||
if (!counts) return { total: 0, pct: 0 };
|
if (!counts) return { total: 0, pct: 0 };
|
||||||
@@ -185,11 +224,11 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
|
|
||||||
<!-- Milestone progress bars -->
|
<!-- Milestone progress bars -->
|
||||||
<div
|
<div
|
||||||
v-if="project.summary?.milestone_summary?.length"
|
v-if="milestoneBars.get(project.id)?.bars.length"
|
||||||
class="milestone-bars"
|
class="milestone-bars"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(ms, i) in project.summary.milestone_summary"
|
v-for="ms in milestoneBars.get(project.id)!.bars"
|
||||||
:key="ms.id"
|
:key="ms.id"
|
||||||
class="milestone-bar-row"
|
class="milestone-bar-row"
|
||||||
:title="`${ms.title} — ${ms.pct}% (${ms.completed}/${ms.total} tasks)`"
|
: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-track">
|
||||||
<div
|
<div
|
||||||
class="milestone-bar-fill"
|
class="milestone-bar-fill"
|
||||||
:style="{ width: ms.pct + '%', background: milestoneColor(i) }"
|
:style="{ width: ms.pct + '%', background: milestoneColor(ms.paletteIndex) }"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<span class="milestone-bar-pct">{{ ms.pct }}%</span>
|
<span class="milestone-bar-pct">{{ ms.pct }}%</span>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
@@ -266,9 +317,9 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.projects-list {
|
.projects-list {
|
||||||
max-width: var(--page-max-width);
|
max-width: var(--fs-layout-page-max);
|
||||||
margin: 2rem auto;
|
margin: 2rem auto;
|
||||||
padding: 0 var(--page-padding-x);
|
padding: 0 var(--fs-layout-page-pad);
|
||||||
overflow-x: clip;
|
overflow-x: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,26 +335,12 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
|
|
||||||
/* Moss action-primary per Hybrid — list-view utility action,
|
/* Moss action-primary per Hybrid — list-view utility action,
|
||||||
not a brand moment. Empty-state .empty-action below keeps accent. */
|
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 {
|
.filter-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
.tab-btn {
|
.tab-btn {
|
||||||
@@ -313,40 +350,40 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
margin-bottom: -1px;
|
margin-bottom: -1px;
|
||||||
}
|
}
|
||||||
.tab-btn:hover {
|
.tab-btn:hover {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.tab-btn.active {
|
.tab-btn.active {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
border-bottom-color: var(--color-primary);
|
border-bottom-color: var(--fs-accent);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-msg,
|
.loading-msg,
|
||||||
.error-msg {
|
.error-msg {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
.error-msg {
|
.error-msg {
|
||||||
color: var(--color-danger);
|
color: var(--fs-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--color-text-muted); }
|
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--fs-text-tertiary); }
|
||||||
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.3; }
|
.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-title { font-size: 1rem; font-weight: 500; color: var(--fs-text-secondary); margin: 0 0 0.35rem; }
|
||||||
.empty-sub { font-size: 0.85rem; margin: 0 0 1rem; }
|
.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 { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--fs-action-primary); border-radius: var(--fs-radius-sm); color: var(--fs-action-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:hover { background: var(--fs-action-primary); color: var(--fs-text-on-action); }
|
||||||
|
|
||||||
.skeleton-card {
|
.skeleton-card {
|
||||||
height: 140px;
|
height: 140px;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--fs-radius-lg);
|
||||||
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
background: linear-gradient(90deg, var(--fs-surface-raised) 25%, var(--fs-border-color) 50%, var(--fs-surface-raised) 75%);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: skeleton-shimmer 1.4s ease infinite;
|
animation: skeleton-shimmer 1.4s ease infinite;
|
||||||
}
|
}
|
||||||
@@ -367,9 +404,9 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.project-card {
|
.project-card {
|
||||||
background: var(--color-bg-card);
|
background: var(--fs-surface-raised);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--fs-radius-lg);
|
||||||
padding: 1rem 1.1rem;
|
padding: 1rem 1.1rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.18s ease;
|
transition: border-color 0.15s, box-shadow 0.15s, transform 0.18s ease;
|
||||||
@@ -378,7 +415,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
.project-card:hover {
|
.project-card:hover {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
box-shadow: 0 2px 8px var(--color-shadow);
|
box-shadow: 0 2px 8px var(--color-shadow);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
@@ -392,7 +429,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
.project-title {
|
.project-title {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -409,32 +446,32 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.status-active {
|
.status-active {
|
||||||
background: color-mix(in srgb, var(--color-success) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-success) 15%, transparent);
|
||||||
color: var(--color-success);
|
color: var(--fs-success);
|
||||||
}
|
}
|
||||||
.status-completed {
|
.status-completed {
|
||||||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.status-archived {
|
.status-archived {
|
||||||
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-goal {
|
.project-goal {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.field-label {
|
.field-label {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-desc {
|
.project-desc {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
@@ -449,18 +486,18 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
.overall-bar-track {
|
.overall-bar-track {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
background: var(--color-border);
|
background: var(--fs-border-color);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.overall-bar-fill {
|
.overall-bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--color-primary);
|
background: var(--fs-accent);
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
.overall-bar-pct {
|
.overall-bar-pct {
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
min-width: 2.5rem;
|
min-width: 2.5rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -480,7 +517,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
.milestone-bar-label {
|
.milestone-bar-label {
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 0 0 30%;
|
flex: 0 0 30%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -490,7 +527,7 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
.milestone-bar-track {
|
.milestone-bar-track {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 5px;
|
height: 5px;
|
||||||
background: var(--color-border);
|
background: var(--fs-border-color);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -500,38 +537,30 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
.milestone-bar-pct {
|
.milestone-bar-pct {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
min-width: 2.5rem;
|
min-width: 2.5rem;
|
||||||
text-align: right;
|
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(--fs-text-tertiary);
|
||||||
|
font-size: var(--fs-size-tiny);
|
||||||
|
padding-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
.card-footer {
|
.card-footer {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
.meta-date {
|
.meta-date {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Modal */
|
|
||||||
.modal-overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: var(--color-overlay, rgba(0,0,0,0.45));
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 200;
|
|
||||||
}
|
|
||||||
.modal-card {
|
.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: 480px;
|
max-width: 480px;
|
||||||
box-shadow: 0 8px 32px var(--color-shadow);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -548,18 +577,18 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
.modal-field label {
|
.modal-field label {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.required {
|
.required {
|
||||||
color: var(--color-danger);
|
color: var(--fs-error);
|
||||||
}
|
}
|
||||||
.modal-input,
|
.modal-input,
|
||||||
.modal-textarea {
|
.modal-textarea {
|
||||||
padding: 0.45rem 0.7rem;
|
padding: 0.45rem 0.7rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@@ -568,41 +597,11 @@ function overallPct(project: Project): { total: number; pct: number } {
|
|||||||
.modal-input:focus,
|
.modal-input:focus,
|
||||||
.modal-textarea:focus {
|
.modal-textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.modal-textarea {
|
.modal-textarea {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
.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-primary {
|
|
||||||
background: var(--color-primary);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.modal-btn-primary:hover:not(:disabled) {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
.modal-btn-primary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.projects-grid {
|
.projects-grid {
|
||||||
|
|||||||
+695
-314
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { apiGet, apiPost } from "@/api/client";
|
import { apiGet, apiPost, apiErrorMessage } from "@/api/client";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import AppLogo from "@/components/AppLogo.vue";
|
import AppLogo from "@/components/AppLogo.vue";
|
||||||
|
|
||||||
@@ -68,12 +68,7 @@ async function handleSubmit() {
|
|||||||
await authStore.checkAuth();
|
await authStore.checkAuth();
|
||||||
router.push("/");
|
router.push("/");
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e && typeof e === "object" && "body" in e) {
|
error.value = apiErrorMessage(e, "Registration failed");
|
||||||
const body = (e as { body?: { error?: string } }).body;
|
|
||||||
error.value = body?.error || "Registration failed";
|
|
||||||
} else {
|
|
||||||
error.value = "Registration failed";
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
@@ -85,9 +80,9 @@ async function handleSubmit() {
|
|||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-brand"><AppLogo :size="32" /><h1>Accept Invitation</h1></div>
|
<div class="auth-brand"><AppLogo :size="32" /><h1>Accept Invitation</h1></div>
|
||||||
|
|
||||||
<div v-if="validating" class="loading-msg">Validating invitation...</div>
|
<div v-if="validating" class="auth-loading">Validating invitation...</div>
|
||||||
|
|
||||||
<div v-else-if="!token || !valid" class="error-block">
|
<div v-else-if="!token || !valid" class="auth-note">
|
||||||
<p>This invitation link is invalid or has expired.</p>
|
<p>This invitation link is invalid or has expired.</p>
|
||||||
<p class="auth-footer">
|
<p class="auth-footer">
|
||||||
<router-link to="/login">Back to Sign In</router-link>
|
<router-link to="/login">Back to Sign In</router-link>
|
||||||
@@ -144,7 +139,7 @@ async function handleSubmit() {
|
|||||||
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
|
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
<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" }}
|
{{ submitting ? "Creating Account..." : "Create Account" }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -157,121 +152,4 @@ async function handleSubmit() {
|
|||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style src="@/assets/auth-shared.css" />
|
||||||
.auth-page {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
.auth-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
.auth-brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.auth-card h1 {
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.loading-msg {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
.error-block {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
.error-block p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
|
||||||
.field {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.field label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
background: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.input:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
.input-error {
|
|
||||||
border-color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.input-error:focus {
|
|
||||||
border-color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.field-hint {
|
|
||||||
margin: 0.35rem 0 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.error-hint {
|
|
||||||
margin: 0.35rem 0 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.error-msg {
|
|
||||||
color: var(--color-danger);
|
|
||||||
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;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin: 1rem 0 0;
|
|
||||||
}
|
|
||||||
.auth-footer a {
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
|
|||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import AppLogo from "@/components/AppLogo.vue";
|
import AppLogo from "@/components/AppLogo.vue";
|
||||||
|
import { apiErrorMessage } from "@/api/client";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
@@ -39,12 +40,7 @@ async function handleSubmit() {
|
|||||||
await authStore.register(username.value, password.value, email.value || undefined);
|
await authStore.register(username.value, password.value, email.value || undefined);
|
||||||
router.push("/");
|
router.push("/");
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e && typeof e === "object" && "body" in e) {
|
error.value = apiErrorMessage(e, "Registration failed");
|
||||||
const body = (e as { body?: { error?: string } }).body;
|
|
||||||
error.value = body?.error || "Registration failed";
|
|
||||||
} else {
|
|
||||||
error.value = "Registration failed";
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
@@ -56,9 +52,9 @@ async function handleSubmit() {
|
|||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-brand"><AppLogo :size="32" /><h1>Create Account</h1></div>
|
<div class="auth-brand"><AppLogo :size="32" /><h1>Create Account</h1></div>
|
||||||
|
|
||||||
<div v-if="checking" class="loading-msg">Checking registration status...</div>
|
<div v-if="checking" class="auth-loading">Checking registration status...</div>
|
||||||
|
|
||||||
<div v-else-if="!authStore.registrationOpen" class="closed-msg">
|
<div v-else-if="!authStore.registrationOpen" class="auth-note">
|
||||||
<p>Registration is currently closed.</p>
|
<p>Registration is currently closed.</p>
|
||||||
<p>Contact an administrator to get an account.</p>
|
<p>Contact an administrator to get an account.</p>
|
||||||
<p class="auth-footer">
|
<p class="auth-footer">
|
||||||
@@ -117,7 +113,7 @@ async function handleSubmit() {
|
|||||||
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
|
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
<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" }}
|
{{ submitting ? "Creating account..." : "Create Account" }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -130,117 +126,4 @@ async function handleSubmit() {
|
|||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style src="@/assets/auth-shared.css" />
|
||||||
.auth-page {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
.auth-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
.auth-brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.auth-card h1 {
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.loading-msg {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding: 1rem 0;
|
|
||||||
}
|
|
||||||
.closed-msg {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
.closed-msg p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
|
||||||
.field {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.field label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
background: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
.input-error {
|
|
||||||
border-color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.input-error:focus {
|
|
||||||
border-color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.field-hint {
|
|
||||||
margin: 0.35rem 0 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.error-hint {
|
|
||||||
margin: 0.35rem 0 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.error-msg {
|
|
||||||
color: var(--color-danger);
|
|
||||||
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;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin: 1rem 0 0;
|
|
||||||
}
|
|
||||||
.auth-footer a {
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { apiPost } from "@/api/client";
|
import { apiPost, apiErrorMessage } from "@/api/client";
|
||||||
import AppLogo from "@/components/AppLogo.vue";
|
import AppLogo from "@/components/AppLogo.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -35,12 +35,7 @@ async function handleSubmit() {
|
|||||||
});
|
});
|
||||||
success.value = true;
|
success.value = true;
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e && typeof e === "object" && "body" in e) {
|
error.value = apiErrorMessage(e, "Failed to reset password");
|
||||||
const body = (e as { body?: { error?: string } }).body;
|
|
||||||
error.value = body?.error || "Failed to reset password";
|
|
||||||
} else {
|
|
||||||
error.value = "Failed to reset password";
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false;
|
submitting.value = false;
|
||||||
}
|
}
|
||||||
@@ -52,7 +47,7 @@ async function handleSubmit() {
|
|||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<div class="auth-brand"><AppLogo :size="32" /><h1>Set New Password</h1></div>
|
<div class="auth-brand"><AppLogo :size="32" /><h1>Set New Password</h1></div>
|
||||||
|
|
||||||
<div v-if="!token" class="error-block">
|
<div v-if="!token" class="auth-note">
|
||||||
<p>Invalid reset link. Please request a new password reset.</p>
|
<p>Invalid reset link. Please request a new password reset.</p>
|
||||||
<p class="auth-footer">
|
<p class="auth-footer">
|
||||||
<router-link to="/forgot-password">Request new link</router-link>
|
<router-link to="/forgot-password">Request new link</router-link>
|
||||||
@@ -88,13 +83,13 @@ async function handleSubmit() {
|
|||||||
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
|
<p v-if="passwordMismatch" class="error-hint">Passwords do not match</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error" class="error-msg">{{ error }}</p>
|
<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" }}
|
{{ submitting ? "Resetting..." : "Reset Password" }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else class="success-msg">
|
<div v-else class="auth-note">
|
||||||
<p>Your password has been reset successfully.</p>
|
<p>Your password has been reset successfully.</p>
|
||||||
<p>You can now sign in with your new password.</p>
|
<p>You can now sign in with your new password.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,120 +101,4 @@ async function handleSubmit() {
|
|||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style src="@/assets/auth-shared.css" />
|
||||||
.auth-page {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
.auth-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
background: var(--color-bg-card);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
.auth-brand {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
.auth-card h1 {
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.error-block {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
.error-block p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
|
||||||
.success-msg {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
padding: 0.5rem 0;
|
|
||||||
}
|
|
||||||
.success-msg p {
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
|
||||||
.field {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.field label {
|
|
||||||
display: block;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
.input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
background: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
.input-error {
|
|
||||||
border-color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.input-error:focus {
|
|
||||||
border-color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.field-hint {
|
|
||||||
margin: 0.35rem 0 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.error-hint {
|
|
||||||
margin: 0.35rem 0 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.error-msg {
|
|
||||||
color: var(--color-danger);
|
|
||||||
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;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
margin: 1rem 0 0;
|
|
||||||
}
|
|
||||||
.auth-footer a {
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -107,10 +107,10 @@ watch(() => route.query, syncFromRoute);
|
|||||||
grid-template-columns: 280px 300px 1fr;
|
grid-template-columns: 280px 300px 1fr;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
background: var(--color-border, #2a2a2e);
|
background: var(--fs-border-color);
|
||||||
}
|
}
|
||||||
.pane.empty {
|
.pane.empty {
|
||||||
background: var(--color-surface, #18181b);
|
background: var(--fs-surface-hover);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|||||||
+591
-609
File diff suppressed because it is too large
Load Diff
@@ -106,11 +106,11 @@ onMounted(async () => {
|
|||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-state {
|
.loading-state {
|
||||||
color: var(--color-muted);
|
color: var(--fs-text-tertiary);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ onMounted(async () => {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.07em;
|
letter-spacing: 0.07em;
|
||||||
color: var(--color-muted);
|
color: var(--fs-text-tertiary);
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +136,9 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.shared-card {
|
.shared-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--color-surface);
|
background: var(--fs-surface-hover);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--fs-radius-xl);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: box-shadow 0.15s, transform 0.15s;
|
transition: box-shadow 0.15s, transform 0.15s;
|
||||||
@@ -151,7 +151,7 @@ onMounted(async () => {
|
|||||||
.card-color-bar {
|
.card-color-bar {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--color-border);
|
background: var(--fs-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-body {
|
.card-body {
|
||||||
@@ -163,7 +163,7 @@ onMounted(async () => {
|
|||||||
.card-title {
|
.card-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
margin-bottom: 0.3rem;
|
margin-bottom: 0.3rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -179,12 +179,12 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.card-owner {
|
.card-owner {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--color-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-desc {
|
.card-desc {
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--color-muted);
|
color: var(--fs-text-tertiary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@@ -203,25 +203,25 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.6rem 0.9rem;
|
padding: 0.6rem 0.9rem;
|
||||||
background: var(--color-surface);
|
background: var(--fs-surface-hover);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
.shared-row:hover {
|
.shared-row:hover {
|
||||||
background: var(--color-hover);
|
background: var(--fs-surface-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-icon {
|
.row-icon {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--color-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.row-title {
|
.row-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -229,7 +229,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
.row-owner {
|
.row-owner {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--color-muted);
|
color: var(--fs-text-tertiary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,12 +242,12 @@ onMounted(async () => {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.perm-viewer { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }
|
.perm-viewer { background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent); color: var(--fs-text-tertiary); }
|
||||||
.perm-editor { background: color-mix(in srgb, var(--color-primary) 15%, transparent); color: var(--color-primary); }
|
.perm-editor { background: color-mix(in srgb, var(--fs-accent) 15%, transparent); color: var(--fs-accent); }
|
||||||
.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(--fs-warning) 15%, transparent); color: var(--fs-warning); }
|
||||||
|
|
||||||
.empty-msg {
|
.empty-msg {
|
||||||
color: var(--color-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user