Compare commits
209
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+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 .
|
||||||
|
|||||||
@@ -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 — a service
|
||||||
|
function that is an instance of the service-function convention and a
|
||||||
|
consumer of 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
|
||||||
|
|
||||||
|
|||||||
+18
-18
@@ -254,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;
|
||||||
@@ -290,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;
|
||||||
@@ -300,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;
|
||||||
}
|
}
|
||||||
@@ -319,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;
|
||||||
@@ -350,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 {
|
||||||
@@ -365,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}` : ""),
|
||||||
|
);
|
||||||
@@ -20,9 +20,13 @@ export interface SnippetFields {
|
|||||||
path: string;
|
path: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
locations: SnippetLocation[];
|
locations: SnippetLocation[];
|
||||||
/** Ids of the snippets folded into this one by merge, oldest first. Read-only:
|
/** The snippets folded into this one by merge, oldest first. Read-only: merge
|
||||||
* merge is the only thing that adds to it, and an edit carries it forward. */
|
* is the only thing that adds to it, and an edit carries it forward.
|
||||||
merged_from: number[];
|
*
|
||||||
|
* 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;
|
code: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +50,29 @@ export interface Snippet {
|
|||||||
owner?: string | null;
|
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`
|
/** Lightweight list item from the knowledge preview feed. Note: the `snippet`
|
||||||
* field here is a truncated *body preview* (the knowledge feed's naming), not
|
* field here is a truncated *body preview* (the knowledge feed's naming), not
|
||||||
* the parsed fields above. */
|
* the parsed fields above. */
|
||||||
@@ -61,6 +88,16 @@ export interface SnippetListItem {
|
|||||||
* them, not one of your own. Absent means it's yours. */
|
* them, not one of your own. Absent means it's yours. */
|
||||||
shared?: boolean;
|
shared?: boolean;
|
||||||
owner?: string | null;
|
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
|
/** Create/update payload — discrete fields the backend serializes into the
|
||||||
@@ -91,6 +128,8 @@ export async function listSnippets(
|
|||||||
repo?: string;
|
repo?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
symbol?: string;
|
symbol?: string;
|
||||||
|
/** Drift check: "attention" | "ok" | "unverified" | "drifted" | a status. */
|
||||||
|
verification?: string;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<{ snippets: SnippetListItem[]; total: number }> {
|
): Promise<{ snippets: SnippetListItem[]; total: number }> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
@@ -100,6 +139,7 @@ export async function listSnippets(
|
|||||||
if (params.repo) qs.set("repo", params.repo);
|
if (params.repo) qs.set("repo", params.repo);
|
||||||
if (params.path) qs.set("path", params.path);
|
if (params.path) qs.set("path", params.path);
|
||||||
if (params.symbol) qs.set("symbol", params.symbol);
|
if (params.symbol) qs.set("symbol", params.symbol);
|
||||||
|
if (params.verification) qs.set("verification", params.verification);
|
||||||
const query = qs.toString();
|
const query = qs.toString();
|
||||||
return apiGet(`/api/snippets${query ? `?${query}` : ""}`);
|
return apiGet(`/api/snippets${query ? `?${query}` : ""}`);
|
||||||
}
|
}
|
||||||
@@ -123,6 +163,45 @@ export async function deleteSnippet(id: number): Promise<void> {
|
|||||||
return apiDelete(`/api/snippets/${id}`);
|
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
|
/** Unify `sourceIds` into the canonical snippet `targetId`. Returns the merged
|
||||||
* survivor plus `merged_ids` — the sources actually folded in and trashed. */
|
* survivor plus `merged_ids` — the sources actually folded in and trashed. */
|
||||||
export async function mergeSnippets(
|
export async function mergeSnippets(
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ router.afterEach(() => {
|
|||||||
<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="/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>
|
||||||
|
|
||||||
@@ -96,6 +102,7 @@ router.afterEach(() => {
|
|||||||
<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="/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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -109,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: "/",
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ export interface Note {
|
|||||||
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;
|
||||||
|
|||||||
@@ -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',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<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>
|
||||||
@@ -156,7 +156,7 @@ onMounted(async () => {
|
|||||||
: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>
|
||||||
@@ -176,69 +176,72 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<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; }
|
||||||
.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>
|
||||||
|
|||||||
@@ -46,6 +46,50 @@ 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; task: number; plan: number; process: number; total: number }
|
interface KnowledgeCounts { note: number; task: number; plan: number; process: number; total: number }
|
||||||
@@ -381,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 -->
|
||||||
@@ -463,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>
|
||||||
@@ -490,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,8 +590,8 @@ 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;
|
||||||
@@ -518,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;
|
||||||
@@ -536,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;
|
||||||
@@ -556,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;
|
||||||
}
|
}
|
||||||
@@ -573,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;
|
||||||
@@ -591,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;
|
||||||
@@ -607,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;
|
||||||
@@ -623,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 {
|
||||||
@@ -636,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;
|
||||||
@@ -645,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; }
|
||||||
@@ -655,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; }
|
||||||
|
|
||||||
@@ -683,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;
|
||||||
@@ -694,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 {
|
||||||
@@ -755,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;
|
||||||
@@ -769,12 +838,12 @@ 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); }
|
||||||
|
|
||||||
/* Top gradient bars */
|
/* Top gradient bars */
|
||||||
@@ -789,7 +858,7 @@ 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;
|
||||||
@@ -808,7 +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--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; }
|
||||||
|
|
||||||
@@ -825,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;
|
||||||
@@ -845,9 +914,9 @@ 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(--color-text-secondary); white-space: nowrap; opacity: 0.7; }
|
.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
|
/* Only rendered for a record another user owns, so an unmarked card is
|
||||||
unambiguously the viewer's own. */
|
unambiguously the viewer's own. */
|
||||||
.shared-tag {
|
.shared-tag {
|
||||||
@@ -855,8 +924,8 @@ onUnmounted(() => {
|
|||||||
padding: 0.08rem 0.35rem;
|
padding: 0.08rem 0.35rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background: color-mix(in srgb, var(--color-text-secondary) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-text-secondary) 15%, transparent);
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Task card ──────────────────────────────────────────── */
|
/* ── Task card ──────────────────────────────────────────── */
|
||||||
@@ -876,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;
|
||||||
@@ -887,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -908,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;
|
||||||
}
|
}
|
||||||
@@ -916,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -930,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 {
|
||||||
@@ -953,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;
|
||||||
|
|||||||
@@ -633,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;
|
||||||
@@ -653,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,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;
|
||||||
@@ -708,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 */
|
||||||
@@ -742,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;
|
||||||
@@ -766,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;
|
||||||
}
|
}
|
||||||
@@ -783,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 {
|
||||||
@@ -801,7 +801,7 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -810,19 +810,19 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
.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;
|
||||||
@@ -830,7 +830,7 @@ onUnmounted(() => assist.clearSelection());
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.prompt-editor:focus {
|
.prompt-editor:focus {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
/* Narrow screen: sidebar collapses */
|
/* Narrow screen: sidebar collapses */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -839,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;
|
||||||
|
|||||||
+577
-479
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;
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
<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 { getSnippet, deleteSnippet, type Snippet } from "@/api/snippets";
|
import {
|
||||||
|
getSnippet,
|
||||||
|
deleteSnippet,
|
||||||
|
unmergeSnippet,
|
||||||
|
type Snippet,
|
||||||
|
} from "@/api/snippets";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||||
|
|
||||||
@@ -31,6 +36,32 @@ function locParts(loc: { repo: string; path: string; symbol: string }): string[]
|
|||||||
return [loc.repo, loc.path, loc.symbol].filter((p) => p && p.trim());
|
return [loc.repo, loc.path, loc.symbol].filter((p) => p && p.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Un-merge (#2165)
|
||||||
|
const unmerging = ref<number | null>(null);
|
||||||
|
|
||||||
|
/** Only entries carrying what the source contributed can be reversed exactly.
|
||||||
|
* Without that, subtraction would be a guess that could strip call sites the
|
||||||
|
* survivor owns in its own right — so the control isn't offered. */
|
||||||
|
function canUnmerge(entry: { locations?: unknown[]; tags?: unknown[] }): boolean {
|
||||||
|
return canWrite.value && (!!entry.locations?.length || !!entry.tags?.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUnmerge(sourceId: number) {
|
||||||
|
unmerging.value = sourceId;
|
||||||
|
try {
|
||||||
|
await unmergeSnippet(id.value, sourceId);
|
||||||
|
toast.show(`#${sourceId} pulled back out and restored`);
|
||||||
|
await load();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// 409 carries the reason the record's state makes it impossible — show it
|
||||||
|
// rather than a generic failure, since it's the actionable part.
|
||||||
|
const detail = (e as { body?: { error?: string } }).body?.error;
|
||||||
|
toast.show(detail || `Couldn't un-merge #${sourceId}`, "error");
|
||||||
|
} finally {
|
||||||
|
unmerging.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
@@ -117,7 +148,27 @@ async function confirmDelete() {
|
|||||||
<template v-if="mergedFrom.length">
|
<template v-if="mergedFrom.length">
|
||||||
<dt>Merged from</dt>
|
<dt>Merged from</dt>
|
||||||
<dd class="merged-from">
|
<dd class="merged-from">
|
||||||
<span v-for="mid in mergedFrom" :key="mid">#{{ mid }}</span>
|
<span v-for="m in mergedFrom" :key="m.id" class="merged-entry">
|
||||||
|
<span>#{{ m.id }}</span>
|
||||||
|
<button
|
||||||
|
v-if="canUnmerge(m)"
|
||||||
|
class="unmerge-btn"
|
||||||
|
:disabled="unmerging === m.id"
|
||||||
|
:title="`Pull #${m.id} back out: restore it and remove the ${(m.locations ?? []).length} location(s) it contributed`"
|
||||||
|
@click="doUnmerge(m.id)"
|
||||||
|
>
|
||||||
|
{{ unmerging === m.id ? "…" : "un-merge" }}
|
||||||
|
</button>
|
||||||
|
<!-- Says why rather than hiding the control: a disabled thing with
|
||||||
|
no explanation reads as a bug. -->
|
||||||
|
<span
|
||||||
|
v-else-if="canWrite"
|
||||||
|
class="unmerge-na"
|
||||||
|
:title="`This merge predates per-source provenance, so what #${m.id} contributed isn't recorded. Restore it from the trash and adjust both records by hand — subtracting a guess could strip call sites this record genuinely owns.`"
|
||||||
|
>
|
||||||
|
(not reversible)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<span class="merged-hint">
|
<span class="merged-hint">
|
||||||
folded in here — the originals are in the trash
|
folded in here — the originals are in the trash
|
||||||
</span>
|
</span>
|
||||||
@@ -154,7 +205,7 @@ async function confirmDelete() {
|
|||||||
.snippet-detail {
|
.snippet-detail {
|
||||||
max-width: 820px;
|
max-width: 820px;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,20 +213,20 @@ async function confirmDelete() {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.back-link:hover {
|
.back-link:hover {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-msg {
|
.state-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);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
@@ -188,7 +239,7 @@ async function confirmDelete() {
|
|||||||
}
|
}
|
||||||
.snippet-name {
|
.snippet-name {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
font-family: var(--fs-font-mono);
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -198,39 +249,11 @@ async function confirmDelete() {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
|
||||||
padding: 0.35rem 0.8rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: border-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.btn-ghost:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
.btn-danger {
|
|
||||||
padding: 0.35rem 0.8rem;
|
|
||||||
border: none;
|
|
||||||
background: var(--color-action-destructive, #6B2118);
|
|
||||||
color: #fff;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.btn-danger:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.when-to-use {
|
.when-to-use {
|
||||||
margin: 0.75rem 0 1.25rem;
|
margin: 0.75rem 0 1.25rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,12 +262,12 @@ async function confirmDelete() {
|
|||||||
.shared-notice {
|
.shared-notice {
|
||||||
margin: 0.75rem 0 0;
|
margin: 0.75rem 0 0;
|
||||||
padding: 0.6rem 0.85rem;
|
padding: 0.6rem 0.85rem;
|
||||||
border-left: 3px solid var(--color-text-muted);
|
border-left: 3px solid var(--fs-text-tertiary);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-grid {
|
.meta-grid {
|
||||||
@@ -257,23 +280,23 @@ async function confirmDelete() {
|
|||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
padding-top: 0.15rem;
|
padding-top: 0.15rem;
|
||||||
}
|
}
|
||||||
.meta-grid dd {
|
.meta-grid dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.meta-grid code,
|
.meta-grid code,
|
||||||
.tag-row + * code {
|
.tag-row + * code {
|
||||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
font-family: var(--fs-font-mono);
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
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);
|
||||||
padding: 0.08rem 0.35rem;
|
padding: 0.08rem 0.35rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
.location-list {
|
.location-list {
|
||||||
@@ -294,29 +317,57 @@ async function confirmDelete() {
|
|||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
.merged-hint {
|
.merged-hint {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
.merged-entry {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
.unmerge-btn {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.05rem 0.35rem;
|
||||||
|
border: 1px solid var(--fs-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.unmerge-btn:hover:not(:disabled) {
|
||||||
|
color: var(--fs-text-primary);
|
||||||
|
border-color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
.unmerge-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.unmerge-na {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
/* Cursor cues that the explanation is in the tooltip. */
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
.code-block {
|
.code-block {
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--fs-radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-bg);
|
background: var(--fs-surface-page);
|
||||||
}
|
}
|
||||||
.code-bar {
|
.code-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.4rem 0.5rem 0.4rem 0.85rem;
|
padding: 0.4rem 0.5rem 0.4rem 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);
|
||||||
}
|
}
|
||||||
.code-lang {
|
.code-lang {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.btn-copy {
|
.btn-copy {
|
||||||
padding: 0.2rem 0.65rem;
|
padding: 0.2rem 0.65rem;
|
||||||
@@ -328,10 +379,10 @@ async function confirmDelete() {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
.code-block code {
|
.code-block code {
|
||||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
font-family: var(--fs-font-mono);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,9 +396,9 @@ async function confirmDelete() {
|
|||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
padding: 0.15rem 0.5rem;
|
padding: 0.15rem 0.5rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--color-bg-secondary);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ function cancel() {
|
|||||||
.snippet-editor {
|
.snippet-editor {
|
||||||
max-width: 760px;
|
max-width: 760px;
|
||||||
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;
|
||||||
}
|
}
|
||||||
.snippet-editor h1 {
|
.snippet-editor h1 {
|
||||||
@@ -376,19 +376,19 @@ function cancel() {
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.back-link:hover {
|
.back-link:hover {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.state-msg {
|
.state-msg {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.error-msg {
|
.error-msg {
|
||||||
color: var(--color-danger);
|
color: var(--fs-error);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,27 +415,27 @@ function cancel() {
|
|||||||
.location-set legend {
|
.location-set legend {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
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);
|
||||||
}
|
}
|
||||||
.hint {
|
.hint {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
margin: 0.1rem 0 0;
|
margin: 0.1rem 0 0;
|
||||||
}
|
}
|
||||||
.hint-inline {
|
.hint-inline {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input {
|
.input {
|
||||||
padding: 0.5rem 0.7rem;
|
padding: 0.5rem 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;
|
||||||
@@ -443,11 +443,11 @@ function cancel() {
|
|||||||
}
|
}
|
||||||
.input:focus {
|
.input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
box-shadow: var(--focus-ring);
|
box-shadow: var(--fs-focus-ring);
|
||||||
}
|
}
|
||||||
.mono {
|
.mono {
|
||||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
font-family: var(--fs-font-mono);
|
||||||
}
|
}
|
||||||
.code-area {
|
.code-area {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -459,8 +459,8 @@ function cancel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.location-set {
|
.location-set {
|
||||||
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: 0.85rem 1rem 1rem;
|
padding: 0.85rem 1rem 1rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -477,32 +477,32 @@ function cancel() {
|
|||||||
.loc-remove {
|
.loc-remove {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.loc-remove:hover {
|
.loc-remove:hover {
|
||||||
border-color: var(--color-danger);
|
border-color: var(--fs-error);
|
||||||
color: var(--color-danger);
|
color: var(--fs-error);
|
||||||
}
|
}
|
||||||
.loc-add {
|
.loc-add {
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
padding: 0.35rem 0.7rem;
|
padding: 0.35rem 0.7rem;
|
||||||
border: 1px dashed var(--color-border);
|
border: 1px dashed var(--fs-border-color);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.loc-add:hover {
|
.loc-add:hover {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
@@ -514,7 +514,7 @@ function cancel() {
|
|||||||
.field-label {
|
.field-label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.systems {
|
.systems {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -528,11 +528,11 @@ function cancel() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.system-opt input {
|
.system-opt input {
|
||||||
accent-color: var(--color-primary);
|
accent-color: var(--fs-accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,25 +541,25 @@ function cancel() {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.85rem 1rem;
|
padding: 0.85rem 1rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-left: 3px solid var(--color-warning, var(--color-primary));
|
border-left: 3px solid var(--fs-warning);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
}
|
}
|
||||||
.duplicate-title {
|
.duplicate-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.duplicate-body {
|
.duplicate-body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
}
|
}
|
||||||
.duplicate-body a {
|
.duplicate-body a {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.duplicate-actions {
|
.duplicate-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -572,37 +572,6 @@ function cancel() {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
.btn-primary {
|
|
||||||
padding: 0.5rem 1.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:not(:disabled) {
|
|
||||||
background: var(--color-action-primary-hover);
|
|
||||||
}
|
|
||||||
.btn-primary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.btn-secondary {
|
|
||||||
padding: 0.5rem 1.1rem;
|
|
||||||
background: var(--color-bg-secondary);
|
|
||||||
color: var(--color-text);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: var(--color-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.field-row,
|
.field-row,
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { listSnippets, mergeSnippets, type SnippetListItem } from "@/api/snippets";
|
import {
|
||||||
|
findDuplicateSnippets,
|
||||||
|
listSnippets,
|
||||||
|
mergeSnippets,
|
||||||
|
type DuplicateGroup,
|
||||||
|
type SnippetListItem,
|
||||||
|
} from "@/api/snippets";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -34,6 +40,15 @@ function toggleLocationFilter() {
|
|||||||
if (!showLocationFilter.value && locationActive.value) clearLocation();
|
if (!showLocationFilter.value && locationActive.value) clearLocation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drift check (#2086) — "attention" is everything whose recorded location or
|
||||||
|
// code no longer checks out, plus everything whose verdict expired because the
|
||||||
|
// snippet was edited since it was checked.
|
||||||
|
const needsAttentionOnly = ref(false);
|
||||||
|
function toggleAttention() {
|
||||||
|
needsAttentionOnly.value = !needsAttentionOnly.value;
|
||||||
|
loadSnippets();
|
||||||
|
}
|
||||||
|
|
||||||
// Multi-select → merge
|
// Multi-select → merge
|
||||||
const selectMode = ref(false);
|
const selectMode = ref(false);
|
||||||
const selectedIds = ref<Set<number>>(new Set());
|
const selectedIds = ref<Set<number>>(new Set());
|
||||||
@@ -41,13 +56,54 @@ const showMergeModal = ref(false);
|
|||||||
const canonicalId = ref<number | null>(null);
|
const canonicalId = ref<number | null>(null);
|
||||||
const merging = ref(false);
|
const merging = ref(false);
|
||||||
|
|
||||||
const selectedList = computed(() =>
|
// Near-duplicate report (#2088). Loaded on demand, not with the list: it's a
|
||||||
snippets.value.filter((s) => selectedIds.value.has(s.id)),
|
// pairwise scan and most visits to this page aren't a tidy-up.
|
||||||
);
|
const duplicateGroups = ref<DuplicateGroup[]>([]);
|
||||||
|
const dupLoading = ref(false);
|
||||||
|
const dupChecked = ref(false);
|
||||||
|
// Set while merging a SUGGESTED group. The report reaches the whole corpus, so
|
||||||
|
// its members need not all be on the current page — see selectedList.
|
||||||
|
const reviewingGroup = ref<DuplicateGroup | null>(null);
|
||||||
|
|
||||||
|
/** The records the merge modal acts on.
|
||||||
|
*
|
||||||
|
* Normally that's the selection filtered against what's on screen. But a
|
||||||
|
* suggested group is corpus-wide: filtering it by the current page would render
|
||||||
|
* an incomplete set AND silently narrow what doMerge folds in, since it derives
|
||||||
|
* its source ids from this list. When a group is under review it is the
|
||||||
|
* authority. */
|
||||||
|
const selectedList = computed<{ id: number; title: string }[]>(() => {
|
||||||
|
if (reviewingGroup.value) return reviewingGroup.value.snippets;
|
||||||
|
return snippets.value.filter((s) => selectedIds.value.has(s.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadDuplicates() {
|
||||||
|
dupLoading.value = true;
|
||||||
|
try {
|
||||||
|
const data = await findDuplicateSnippets();
|
||||||
|
duplicateGroups.value = data.groups;
|
||||||
|
dupChecked.value = true;
|
||||||
|
} catch {
|
||||||
|
toast.show("Couldn't check for duplicates", "error");
|
||||||
|
} finally {
|
||||||
|
dupLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hand a suggested group to the existing merge flow, pre-selected. The operator
|
||||||
|
* still picks which record survives and confirms — the report proposes, it
|
||||||
|
* never merges. */
|
||||||
|
function reviewGroup(group: DuplicateGroup) {
|
||||||
|
reviewingGroup.value = group;
|
||||||
|
selectedIds.value = new Set(group.note_ids);
|
||||||
|
canonicalId.value = group.note_ids[0] ?? null;
|
||||||
|
showMergeModal.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
function exitSelectMode() {
|
function exitSelectMode() {
|
||||||
selectMode.value = false;
|
selectMode.value = false;
|
||||||
selectedIds.value = new Set();
|
selectedIds.value = new Set();
|
||||||
|
reviewingGroup.value = null;
|
||||||
}
|
}
|
||||||
function toggleSelectMode() {
|
function toggleSelectMode() {
|
||||||
if (selectMode.value) exitSelectMode();
|
if (selectMode.value) exitSelectMode();
|
||||||
@@ -68,6 +124,15 @@ function openMerge() {
|
|||||||
canonicalId.value = selectedList.value[0]?.id ?? null;
|
canonicalId.value = selectedList.value[0]?.id ?? null;
|
||||||
showMergeModal.value = true;
|
showMergeModal.value = true;
|
||||||
}
|
}
|
||||||
|
/** Dismiss the modal. Clears the reviewed group too — leaving it set would keep
|
||||||
|
* selectedList pinned to a corpus-wide set the operator has walked away from. */
|
||||||
|
function closeMerge() {
|
||||||
|
showMergeModal.value = false;
|
||||||
|
if (reviewingGroup.value) {
|
||||||
|
reviewingGroup.value = null;
|
||||||
|
selectedIds.value = new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
async function doMerge() {
|
async function doMerge() {
|
||||||
const target = canonicalId.value;
|
const target = canonicalId.value;
|
||||||
if (target == null) return;
|
if (target == null) return;
|
||||||
@@ -78,8 +143,13 @@ async function doMerge() {
|
|||||||
await mergeSnippets(target, sources);
|
await mergeSnippets(target, sources);
|
||||||
toast.show(`Merged ${sources.length} snippet${sources.length > 1 ? "s" : ""} in`);
|
toast.show(`Merged ${sources.length} snippet${sources.length > 1 ? "s" : ""} in`);
|
||||||
showMergeModal.value = false;
|
showMergeModal.value = false;
|
||||||
|
const wasSuggested = reviewingGroup.value !== null;
|
||||||
exitSelectMode();
|
exitSelectMode();
|
||||||
await loadSnippets();
|
await loadSnippets();
|
||||||
|
// The merged-away records are gone, so a stale report would keep offering
|
||||||
|
// them. Re-run it rather than clearing, so the operator can work through
|
||||||
|
// several groups without re-triggering the scan each time.
|
||||||
|
if (wasSuggested && dupChecked.value) await loadDuplicates();
|
||||||
} catch {
|
} catch {
|
||||||
toast.show("Failed to merge snippets", "error");
|
toast.show("Failed to merge snippets", "error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -96,6 +166,7 @@ async function loadSnippets() {
|
|||||||
repo: locRepo.value.trim() || undefined,
|
repo: locRepo.value.trim() || undefined,
|
||||||
path: locPath.value.trim() || undefined,
|
path: locPath.value.trim() || undefined,
|
||||||
symbol: locSymbol.value.trim() || undefined,
|
symbol: locSymbol.value.trim() || undefined,
|
||||||
|
verification: needsAttentionOnly.value ? "attention" : undefined,
|
||||||
});
|
});
|
||||||
snippets.value = data.snippets;
|
snippets.value = data.snippets;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -126,6 +197,79 @@ function splitTitle(title: string): { name: string; when: string } {
|
|||||||
function languageOf(tags: string[]): string {
|
function languageOf(tags: string[]): string {
|
||||||
return tags.find((t) => t && t !== "snippet") ?? "";
|
return tags.find((t) => t && t !== "snippet") ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A snippet that has been offered repeatedly and never opened. The threshold
|
||||||
|
* is 3 rather than 1 because one or two surfacings is noise — the record may
|
||||||
|
* simply not have come up in a relevant context yet. */
|
||||||
|
function isDeadWeight(s: SnippetListItem): boolean {
|
||||||
|
const u = s.usage;
|
||||||
|
return !!u && u.pull_count === 0 && u.surfaced_count >= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short badge text, or "" to render nothing. A record nobody has surfaced yet
|
||||||
|
* gets no badge at all: "0 / 0" would read as a verdict when it's an absence
|
||||||
|
* of evidence. */
|
||||||
|
function usageBadge(s: SnippetListItem): string {
|
||||||
|
const u = s.usage;
|
||||||
|
if (!u || u.surfaced_count === 0) return "";
|
||||||
|
return `${u.pull_count}/${u.surfaced_count} used`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short label for the drift verdict, or "" when there's nothing to say.
|
||||||
|
* An expired verdict is reported as "unchecked" whatever it used to say —
|
||||||
|
* it was about code that is no longer in the record. */
|
||||||
|
function driftBadge(s: SnippetListItem): string {
|
||||||
|
const v = s.verification;
|
||||||
|
if (!v) return "";
|
||||||
|
if (!v.current) return "unchecked since edit";
|
||||||
|
// Record<string, string>, not an object literal: `status` is a union that
|
||||||
|
// includes "ok" and "unverified", and a literal would have to enumerate every
|
||||||
|
// member just to say "nothing to show for these".
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
missing: "path gone",
|
||||||
|
moved: "symbol moved",
|
||||||
|
changed: "code drifted",
|
||||||
|
};
|
||||||
|
return labels[v.status] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function driftTitle(s: SnippetListItem): string {
|
||||||
|
const v = s.verification;
|
||||||
|
if (!v) return "";
|
||||||
|
const when = v.checked_at
|
||||||
|
? `Checked ${new Date(v.checked_at).toLocaleDateString()}`
|
||||||
|
: "Checked";
|
||||||
|
if (!v.current) {
|
||||||
|
return (
|
||||||
|
`${when}, but the snippet has been edited since — that verdict was about ` +
|
||||||
|
`code this record no longer holds. Re-verify it.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const reasons: Record<string, string> = {
|
||||||
|
missing: "the recorded path no longer exists",
|
||||||
|
moved: "the file is there but the symbol isn't in it",
|
||||||
|
changed: "the source no longer matches the recorded code",
|
||||||
|
};
|
||||||
|
const what = reasons[v.status] ?? "";
|
||||||
|
return v.detail ? `${when}: ${what}. ${v.detail}` : `${when}: ${what}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageTitle(s: SnippetListItem): string {
|
||||||
|
const u = s.usage;
|
||||||
|
if (!u) return "";
|
||||||
|
const last = u.last_pulled_at
|
||||||
|
? `Last opened ${new Date(u.last_pulled_at).toLocaleDateString()}.`
|
||||||
|
: "Never opened.";
|
||||||
|
const verdict = isDeadWeight(s)
|
||||||
|
? " Offered repeatedly without ever being opened — consider rewriting its" +
|
||||||
|
" “when to reach for it” so it says when, or deleting it. It takes a slot" +
|
||||||
|
" in every future auto-inject menu."
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
`Surfaced to an agent ${u.surfaced_count}×, opened in full ` +
|
||||||
|
`${u.pull_count}×. ${last}${verdict}`
|
||||||
|
);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -167,6 +311,47 @@ function languageOf(tags: string[]): string {
|
|||||||
a screen reader too. -->
|
a screen reader too. -->
|
||||||
{{ locationActive ? "Location · filtering" : "Location" }}
|
{{ locationActive ? "Location · filtering" : "Location" }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-ghost"
|
||||||
|
:class="{ 'filter-on': needsAttentionOnly }"
|
||||||
|
:aria-pressed="needsAttentionOnly"
|
||||||
|
title="Show only snippets whose recorded location or code no longer checks out — including ones edited since they were last verified"
|
||||||
|
@click="toggleAttention"
|
||||||
|
>
|
||||||
|
{{ needsAttentionOnly ? "Needs attention · filtering" : "Needs attention" }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-ghost"
|
||||||
|
:disabled="dupLoading"
|
||||||
|
title="Look for snippets already recorded that resemble each other closely enough to be worth merging"
|
||||||
|
@click="loadDuplicates"
|
||||||
|
>
|
||||||
|
{{ dupLoading ? "Checking…" : "Find duplicates" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Near-duplicate report. Only ever a proposal — merging is a separate,
|
||||||
|
confirmed act, and the operator chooses which record survives. -->
|
||||||
|
<div v-if="dupChecked && !dupLoading" class="dup-panel">
|
||||||
|
<p v-if="!duplicateGroups.length" class="dup-empty">
|
||||||
|
No near-duplicates found. Nothing recorded resembles anything else closely
|
||||||
|
enough to be worth merging.
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<p class="dup-head">
|
||||||
|
{{ duplicateGroups.length }} possible duplicate{{ duplicateGroups.length > 1 ? " sets" : " set" }}
|
||||||
|
— review each before merging; a set is a suggestion, not a verdict.
|
||||||
|
</p>
|
||||||
|
<div v-for="(g, i) in duplicateGroups" :key="i" class="dup-group">
|
||||||
|
<div class="dup-members">
|
||||||
|
<span v-for="s in g.snippets" :key="s.id" class="dup-member">
|
||||||
|
{{ splitTitle(s.title).name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="dup-score">{{ Math.round(g.top_score * 100) }}% alike</span>
|
||||||
|
<button class="btn-ghost dup-action" @click="reviewGroup(g)">Review & merge</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Reverse lookup: what's already kept in this repo / file / symbol. -->
|
<!-- Reverse lookup: what's already kept in this repo / file / symbol. -->
|
||||||
@@ -209,20 +394,27 @@ function languageOf(tags: string[]): string {
|
|||||||
<div v-else-if="snippets.length === 0" class="empty-state-rich">
|
<div v-else-if="snippets.length === 0" class="empty-state-rich">
|
||||||
<div class="empty-icon">❭_</div>
|
<div class="empty-icon">❭_</div>
|
||||||
<p class="empty-title">
|
<p class="empty-title">
|
||||||
{{ locationActive
|
{{ needsAttentionOnly
|
||||||
? "Nothing kept at that location yet"
|
? "Everything checks out"
|
||||||
: search.trim()
|
: locationActive
|
||||||
? "No snippets match your search"
|
? "Nothing kept at that location yet"
|
||||||
: "No snippets kept yet" }}
|
: search.trim()
|
||||||
|
? "No snippets match your search"
|
||||||
|
: "No snippets kept yet" }}
|
||||||
</p>
|
</p>
|
||||||
<p class="empty-sub">
|
<p class="empty-sub">
|
||||||
{{ locationActive
|
{{ needsAttentionOnly
|
||||||
? "No recorded snippet lives there — so whatever you're about to write is new. Widen the path, or clear the filter."
|
? "No snippet has drifted from its recorded location or code — as far as anything has been checked. Snippets nobody has verified yet don't appear here."
|
||||||
: search.trim()
|
: locationActive
|
||||||
? "Try a different term, or clear the search."
|
? "No recorded snippet lives there — so whatever you're about to write is new. Widen the path, or clear the filter."
|
||||||
: "Record a reusable function or component and it will be offered back to you later." }}
|
: search.trim()
|
||||||
|
? "Try a different term, or clear the search."
|
||||||
|
: "Record a reusable function or component and it will be offered back to you later." }}
|
||||||
</p>
|
</p>
|
||||||
<button v-if="locationActive" class="empty-action" @click="clearLocation">
|
<button v-if="needsAttentionOnly" class="empty-action" @click="toggleAttention">
|
||||||
|
Show all snippets
|
||||||
|
</button>
|
||||||
|
<button v-else-if="locationActive" class="empty-action" @click="clearLocation">
|
||||||
Clear location filter
|
Clear location filter
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -261,6 +453,17 @@ function languageOf(tags: string[]): string {
|
|||||||
</p>
|
</p>
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<span class="meta-date">Updated {{ new Date(s.updated_at).toLocaleDateString() }}</span>
|
<span class="meta-date">Updated {{ new Date(s.updated_at).toLocaleDateString() }}</span>
|
||||||
|
<span v-if="driftBadge(s)" class="drift-tag" :title="driftTitle(s)">
|
||||||
|
{{ driftBadge(s) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="usageBadge(s)"
|
||||||
|
class="usage-tag"
|
||||||
|
:class="{ 'usage-dead': isDeadWeight(s) }"
|
||||||
|
:title="usageTitle(s)"
|
||||||
|
>
|
||||||
|
{{ usageBadge(s) }}
|
||||||
|
</span>
|
||||||
<span v-if="s.shared" class="shared-tag" :title="`Shared by ${s.owner ?? 'another user'} — a suggestion, not your own record`">
|
<span v-if="s.shared" class="shared-tag" :title="`Shared by ${s.owner ?? 'another user'} — a suggestion, not your own record`">
|
||||||
by {{ s.owner ?? "another user" }}
|
by {{ s.owner ?? "another user" }}
|
||||||
</span>
|
</span>
|
||||||
@@ -279,7 +482,7 @@ function languageOf(tags: string[]): string {
|
|||||||
|
|
||||||
<!-- Merge modal -->
|
<!-- Merge modal -->
|
||||||
<teleport to="body">
|
<teleport to="body">
|
||||||
<div v-if="showMergeModal" class="modal-overlay" @click.self="showMergeModal = false">
|
<div v-if="showMergeModal" class="modal-overlay" @click.self="closeMerge">
|
||||||
<div class="modal-card" role="dialog" aria-modal="true" aria-label="Merge snippets">
|
<div class="modal-card" role="dialog" aria-modal="true" aria-label="Merge snippets">
|
||||||
<h3 class="modal-title">Merge snippets</h3>
|
<h3 class="modal-title">Merge snippets</h3>
|
||||||
<p class="modal-desc">
|
<p class="modal-desc">
|
||||||
@@ -299,7 +502,7 @@ function languageOf(tags: string[]): string {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="modal-btn" @click="showMergeModal = false">Cancel</button>
|
<button class="modal-btn" @click="closeMerge">Cancel</button>
|
||||||
<button
|
<button
|
||||||
class="modal-btn modal-btn-primary"
|
class="modal-btn modal-btn-primary"
|
||||||
:disabled="merging || canonicalId == null"
|
:disabled="merging || canonicalId == null"
|
||||||
@@ -316,9 +519,9 @@ function languageOf(tags: string[]): string {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.snippets-list {
|
.snippets-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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,28 +536,13 @@ function languageOf(tags: string[]): string {
|
|||||||
}
|
}
|
||||||
.page-sub {
|
.page-sub {
|
||||||
margin: 0 0 1.25rem;
|
margin: 0 0 1.25rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
max-width: 60ch;
|
max-width: 60ch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Moss action-primary per Hybrid — utility action, not a brand moment. */
|
/* Moss action-primary per Hybrid — utility action, not a brand moment. */
|
||||||
.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;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.btn-primary:hover {
|
|
||||||
background: var(--color-action-primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-row {
|
.search-row {
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
@@ -365,8 +553,8 @@ function languageOf(tags: string[]): string {
|
|||||||
}
|
}
|
||||||
/* Filter is engaged — the accent marks "you are here", per the design system. */
|
/* Filter is engaged — the accent marks "you are here", per the design system. */
|
||||||
.filter-on {
|
.filter-on {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.location-row {
|
.location-row {
|
||||||
@@ -381,12 +569,12 @@ function languageOf(tags: string[]): string {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 12rem;
|
max-width: 12rem;
|
||||||
padding: 0.4rem 0.65rem;
|
padding: 0.4rem 0.65rem;
|
||||||
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;
|
||||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
font-family: var(--fs-font-mono);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.loc-input-wide {
|
.loc-input-wide {
|
||||||
@@ -395,46 +583,46 @@ function languageOf(tags: string[]): string {
|
|||||||
}
|
}
|
||||||
.loc-input::placeholder {
|
.loc-input::placeholder {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.loc-input:focus {
|
.loc-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
box-shadow: var(--focus-ring);
|
box-shadow: var(--fs-focus-ring);
|
||||||
}
|
}
|
||||||
.loc-clear {
|
.loc-clear {
|
||||||
padding: 0.4rem 0.65rem;
|
padding: 0.4rem 0.65rem;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
.loc-clear:hover {
|
.loc-clear:hover {
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.search-input {
|
.search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
padding: 0.5rem 0.8rem;
|
padding: 0.5rem 0.8rem;
|
||||||
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;
|
||||||
}
|
}
|
||||||
.search-input:focus {
|
.search-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
box-shadow: var(--focus-ring);
|
box-shadow: var(--fs-focus-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-msg {
|
.error-msg {
|
||||||
color: var(--color-danger);
|
color: var(--fs-error);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
@@ -442,10 +630,10 @@ function languageOf(tags: string[]): string {
|
|||||||
.empty-state-rich {
|
.empty-state-rich {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 3rem 1rem;
|
padding: 3rem 1rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.empty-icon {
|
.empty-icon {
|
||||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
font-family: var(--fs-font-mono);
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
@@ -453,7 +641,7 @@ function languageOf(tags: string[]): string {
|
|||||||
.empty-title {
|
.empty-title {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
margin: 0 0 0.35rem;
|
margin: 0 0 0.35rem;
|
||||||
}
|
}
|
||||||
.empty-sub {
|
.empty-sub {
|
||||||
@@ -466,17 +654,17 @@ function languageOf(tags: string[]): string {
|
|||||||
.empty-action {
|
.empty-action {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.4rem 1rem;
|
||||||
border: 1px solid var(--color-primary);
|
border: 1px solid var(--fs-action-primary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
color: var(--color-primary);
|
color: var(--fs-action-primary);
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.empty-action:hover {
|
.empty-action:hover {
|
||||||
background: var(--color-primary);
|
background: var(--fs-action-primary);
|
||||||
color: #fff;
|
color: var(--fs-text-on-action);
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-grid,
|
.skeleton-grid,
|
||||||
@@ -487,8 +675,8 @@ function languageOf(tags: string[]): string {
|
|||||||
}
|
}
|
||||||
.skeleton-card {
|
.skeleton-card {
|
||||||
height: 96px;
|
height: 96px;
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -498,9 +686,9 @@ function languageOf(tags: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.snippet-card {
|
.snippet-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: 0.9rem 1rem;
|
padding: 0.9rem 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;
|
||||||
@@ -509,14 +697,14 @@ function languageOf(tags: string[]): string {
|
|||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
.snippet-card:hover {
|
.snippet-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);
|
||||||
}
|
}
|
||||||
.snippet-card:focus-visible {
|
.snippet-card:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
box-shadow: var(--focus-ring);
|
box-shadow: var(--fs-focus-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
@@ -528,11 +716,11 @@ function languageOf(tags: string[]): string {
|
|||||||
.snippet-name {
|
.snippet-name {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
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;
|
||||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
font-family: var(--fs-font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Language tag — accent pill per the design system's tag treatment. */
|
/* Language tag — accent pill per the design system's tag treatment. */
|
||||||
@@ -543,13 +731,13 @@ function languageOf(tags: string[]): string {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.snippet-when {
|
.snippet-when {
|
||||||
font-size: 0.83rem;
|
font-size: 0.83rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
@@ -563,7 +751,7 @@ function languageOf(tags: string[]): string {
|
|||||||
}
|
}
|
||||||
.meta-date {
|
.meta-date {
|
||||||
font-size: 0.73rem;
|
font-size: 0.73rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
/* Marks a record someone else owns. Present only on shared rows, so an
|
/* Marks a record someone else owns. Present only on shared rows, so an
|
||||||
unmarked card is unambiguously the operator's own. */
|
unmarked card is unambiguously the operator's own. */
|
||||||
@@ -572,8 +760,93 @@ function languageOf(tags: string[]): string {
|
|||||||
padding: 0.1rem 0.4rem;
|
padding: 0.1rem 0.4rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Near-duplicate report */
|
||||||
|
.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);
|
||||||
|
/* Long snippet names must not push the row into a horizontal scroll. */
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dup-score {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dup-action {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drift is a stronger signal than dead weight: the record may be actively
|
||||||
|
misleading, not merely unused. Danger tone, and it sits first in the footer. */
|
||||||
|
.drift-tag {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: color-mix(in srgb, var(--fs-error) 15%, transparent);
|
||||||
|
color: var(--fs-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.usage-tag {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
background: color-mix(in srgb, var(--fs-text-tertiary) 15%, transparent);
|
||||||
|
color: var(--fs-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dead weight is a nudge, not an error — it warns in the warning colour rather
|
||||||
|
than the danger one, because the record isn't broken, just unearned. */
|
||||||
|
.usage-tag.usage-dead {
|
||||||
|
background: color-mix(in srgb, var(--fs-warning) 18%, transparent);
|
||||||
|
color: var(--fs-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header + select-mode */
|
/* Header + select-mode */
|
||||||
@@ -582,25 +855,10 @@ function languageOf(tags: string[]): string {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.btn-ghost {
|
|
||||||
padding: 0.4rem 0.85rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: border-color 0.15s, color 0.15s;
|
|
||||||
}
|
|
||||||
.btn-ghost:hover {
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Selected card = 2px accent border per the design system (featured/active). */
|
/* Selected card = 2px accent border per the design system (featured/active). */
|
||||||
.snippet-card.selected {
|
.snippet-card.selected {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
padding: calc(0.9rem - 1px) calc(1rem - 1px);
|
padding: calc(0.9rem - 1px) calc(1rem - 1px);
|
||||||
}
|
}
|
||||||
@@ -609,14 +867,14 @@ function languageOf(tags: string[]): string {
|
|||||||
height: 16px;
|
height: 16px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
transition: background 0.12s, border-color 0.12s;
|
transition: background 0.12s, border-color 0.12s;
|
||||||
}
|
}
|
||||||
.select-box.on {
|
.select-box.on {
|
||||||
background: var(--color-primary);
|
background: var(--fs-accent);
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-bar {
|
.select-bar {
|
||||||
@@ -627,45 +885,26 @@ function languageOf(tags: string[]): string {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.6rem 0.9rem;
|
padding: 0.6rem 0.9rem;
|
||||||
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);
|
box-shadow: 0 4px 16px var(--color-shadow);
|
||||||
}
|
}
|
||||||
.select-count {
|
.select-count {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
}
|
}
|
||||||
.select-hint {
|
.select-hint {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.select-bar .btn-primary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Merge modal */
|
/* Merge 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: 460px;
|
max-width: 460px;
|
||||||
box-shadow: 0 8px 32px var(--color-shadow);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -677,7 +916,7 @@ function languageOf(tags: string[]): string {
|
|||||||
.modal-desc {
|
.modal-desc {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.merge-choices {
|
.merge-choices {
|
||||||
@@ -690,18 +929,18 @@ function languageOf(tags: string[]): string {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
padding: 0.5rem 0.7rem;
|
padding: 0.5rem 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);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.merge-choice.chosen {
|
.merge-choice.chosen {
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 8%, transparent);
|
||||||
}
|
}
|
||||||
.merge-choice-name {
|
.merge-choice-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-family: var(--font-mono, ui-monospace, "JetBrains Mono", monospace);
|
font-family: var(--fs-font-mono);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
@@ -709,39 +948,9 @@ function languageOf(tags: string[]): string {
|
|||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.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-action-primary);
|
|
||||||
border-color: var(--color-action-primary);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.modal-btn-primary:hover:not(:disabled) {
|
|
||||||
background: var(--color-action-primary-hover);
|
|
||||||
}
|
|
||||||
.modal-btn-primary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.snippets-grid {
|
.snippets-grid {
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const toast = useToastStore();
|
|||||||
const title = ref("");
|
const title = ref("");
|
||||||
const body = ref("");
|
const body = ref("");
|
||||||
const description = ref("");
|
const description = ref("");
|
||||||
const consolidatedAt = ref<string | null>(null);
|
|
||||||
const tags = ref<string[]>([]);
|
const tags = ref<string[]>([]);
|
||||||
const status = ref<TaskStatus>("todo");
|
const status = ref<TaskStatus>("todo");
|
||||||
const priority = ref<TaskPriority>("none");
|
const priority = ref<TaskPriority>("none");
|
||||||
@@ -303,7 +302,6 @@ onMounted(async () => {
|
|||||||
title.value = store.currentTask.title;
|
title.value = store.currentTask.title;
|
||||||
body.value = store.currentTask.body;
|
body.value = store.currentTask.body;
|
||||||
description.value = store.currentTask.description ?? "";
|
description.value = store.currentTask.description ?? "";
|
||||||
consolidatedAt.value = store.currentTask.consolidated_at ?? null;
|
|
||||||
tags.value = [...(store.currentTask.tags || [])];
|
tags.value = [...(store.currentTask.tags || [])];
|
||||||
status.value = store.currentTask.status as TaskStatus;
|
status.value = store.currentTask.status as TaskStatus;
|
||||||
priority.value = store.currentTask.priority as TaskPriority;
|
priority.value = store.currentTask.priority as TaskPriority;
|
||||||
@@ -645,7 +643,7 @@ useEditorGuards(dirty, save);
|
|||||||
@focus="onParentFocus"
|
@focus="onParentFocus"
|
||||||
@blur="hideParentDropdown"
|
@blur="hideParentDropdown"
|
||||||
/>
|
/>
|
||||||
<button v-if="parentId" class="btn-clear-parent" @click="clearParentTask" title="Clear">×</button>
|
<button v-if="parentId" class="btn-text btn-clear-parent" @click="clearParentTask" title="Clear">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showParentDropdown" class="parent-dropdown">
|
<div v-if="showParentDropdown" class="parent-dropdown">
|
||||||
<div v-if="parentSearchLoading" class="parent-dropdown-item parent-empty">Searching...</div>
|
<div v-if="parentSearchLoading" class="parent-dropdown-item parent-empty">Searching...</div>
|
||||||
@@ -666,7 +664,7 @@ useEditorGuards(dirty, save);
|
|||||||
<div v-if="isEditing" class="subtasks-section">
|
<div v-if="isEditing" class="subtasks-section">
|
||||||
<div class="subtasks-header">
|
<div class="subtasks-header">
|
||||||
<span class="subtasks-label">Sub-tasks</span>
|
<span class="subtasks-label">Sub-tasks</span>
|
||||||
<button class="btn-add-subtask" @click="addingSubTask = !addingSubTask">+ Add</button>
|
<button class="btn-text" @click="addingSubTask = !addingSubTask">+ Add</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="subTasksLoading" class="subtasks-loading">Loading...</div>
|
<div v-if="subTasksLoading" class="subtasks-loading">Loading...</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -686,8 +684,8 @@ useEditorGuards(dirty, save);
|
|||||||
@keydown.escape="addingSubTask = false; newSubTaskTitle = ''"
|
@keydown.escape="addingSubTask = false; newSubTaskTitle = ''"
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
<button class="btn-subtask-confirm" @click="createSubTask" :disabled="!newSubTaskTitle.trim()">Add</button>
|
<button class="btn-primary btn-compact" @click="createSubTask" :disabled="!newSubTaskTitle.trim()">Add</button>
|
||||||
<button class="btn-subtask-cancel" @click="addingSubTask = false; newSubTaskTitle = ''">Cancel</button>
|
<button class="btn-ghost btn-compact" @click="addingSubTask = false; newSubTaskTitle = ''">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -832,7 +830,7 @@ useEditorGuards(dirty, save);
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* .task-main is a flex column; without flex-shrink: 0, long body content
|
/* .task-main is a flex column; without flex-shrink: 0, long body content
|
||||||
@@ -854,7 +852,7 @@ useEditorGuards(dirty, save);
|
|||||||
.task-sidebar {
|
.task-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;
|
||||||
@@ -868,21 +866,21 @@ useEditorGuards(dirty, save);
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0 0.2rem;
|
padding: 0 0.2rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.btn-clear-parent:hover { color: var(--color-danger, #e74c3c); }
|
.btn-clear-parent:hover { color: var(--fs-error); }
|
||||||
.parent-dropdown {
|
.parent-dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 4px);
|
top: calc(100% + 4px);
|
||||||
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: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
box-shadow: 0 4px 12px var(--color-shadow);
|
box-shadow: 0 4px 12px var(--color-shadow);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
@@ -892,21 +890,21 @@ useEditorGuards(dirty, save);
|
|||||||
padding: 0.4rem 0.65rem;
|
padding: 0.4rem 0.65rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
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;
|
||||||
}
|
}
|
||||||
.parent-dropdown-item:hover { background: var(--color-bg-secondary); }
|
.parent-dropdown-item:hover { background: var(--fs-surface-raised); }
|
||||||
.parent-empty { color: var(--color-text-muted); cursor: default; }
|
.parent-empty { color: var(--fs-text-tertiary); cursor: default; }
|
||||||
.parent-empty:hover { background: none; }
|
.parent-empty:hover { background: none; }
|
||||||
|
|
||||||
/* Sub-tasks */
|
/* Sub-tasks */
|
||||||
.subtasks-section {
|
.subtasks-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);
|
||||||
padding: 0.5rem 0.65rem;
|
padding: 0.5rem 0.65rem;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
}
|
}
|
||||||
.subtasks-header {
|
.subtasks-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -917,40 +915,23 @@ useEditorGuards(dirty, save);
|
|||||||
.subtasks-label {
|
.subtasks-label {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
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;
|
||||||
}
|
}
|
||||||
.btn-add-subtask {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-family: inherit;
|
|
||||||
padding: 0.1rem 0.2rem;
|
|
||||||
}
|
|
||||||
.btn-add-subtask:hover { opacity: 0.8; }
|
|
||||||
.subtasks-loading { font-size: 0.78rem; color: var(--color-text-muted); }
|
|
||||||
.subtask-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
padding: 0.2rem 0;
|
|
||||||
}
|
|
||||||
.subtask-checkbox { flex-shrink: 0; cursor: pointer; }
|
.subtask-checkbox { flex-shrink: 0; cursor: pointer; }
|
||||||
.subtask-title {
|
.subtask-title {
|
||||||
font-size: 0.83rem;
|
font-size: 0.83rem;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.subtask-title:hover { color: var(--color-primary); }
|
.subtask-title:hover { color: var(--fs-accent); }
|
||||||
.subtask-title.done { text-decoration: line-through; color: var(--color-text-muted); }
|
.subtask-title.done { text-decoration: line-through; color: var(--fs-text-tertiary); }
|
||||||
.subtasks-empty { font-size: 0.78rem; color: var(--color-text-muted); margin: 0; }
|
.subtasks-empty { font-size: 0.78rem; color: var(--fs-text-tertiary); margin: 0; }
|
||||||
.subtask-add-row {
|
.subtask-add-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -960,46 +941,19 @@ useEditorGuards(dirty, save);
|
|||||||
.subtask-input {
|
.subtask-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
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: 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.83rem;
|
font-size: 0.83rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.subtask-input:focus { outline: none; border-color: var(--color-primary); }
|
.subtask-input:focus { outline: none; border-color: var(--fs-accent); }
|
||||||
.btn-subtask-confirm {
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.btn-subtask-confirm:disabled { opacity: 0.5; cursor: default; }
|
|
||||||
.btn-subtask-cancel {
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
background: none;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Streaming preview */
|
|
||||||
.stream-label {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
}
|
|
||||||
.stream-preview {
|
.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;
|
||||||
}
|
}
|
||||||
.main-diff {
|
.main-diff {
|
||||||
@@ -1009,9 +963,9 @@ useEditorGuards(dirty, save);
|
|||||||
|
|
||||||
/* Systems multi-select (in sidebar) */
|
/* Systems multi-select (in sidebar) */
|
||||||
.sb-systems { display: flex; flex-direction: column; gap: 0.25rem; max-height: 160px; overflow-y: auto; }
|
.sb-systems { display: flex; flex-direction: column; gap: 0.25rem; max-height: 160px; overflow-y: auto; }
|
||||||
.sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--color-text); cursor: pointer; }
|
.sb-system-opt { display: flex; align-items: center; gap: 0.45rem; font-size: 0.85rem; color: var(--fs-text-primary); cursor: pointer; }
|
||||||
.sb-system-opt input { accent-color: var(--color-primary); cursor: pointer; }
|
.sb-system-opt input { accent-color: var(--fs-accent); cursor: pointer; }
|
||||||
.sb-systems-empty { margin: 0; font-size: 0.8rem; color: var(--color-text-muted); }
|
.sb-systems-empty { margin: 0; font-size: 0.8rem; color: var(--fs-text-tertiary); }
|
||||||
|
|
||||||
/* Writing Assistant section (in sidebar) */
|
/* Writing Assistant section (in sidebar) */
|
||||||
.assist-section {
|
.assist-section {
|
||||||
@@ -1022,7 +976,7 @@ useEditorGuards(dirty, save);
|
|||||||
.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;
|
||||||
}
|
}
|
||||||
@@ -1053,11 +1007,11 @@ useEditorGuards(dirty, save);
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
.sb-ts-label {
|
.sb-ts-label {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.sb-ts-value {
|
.sb-ts-value {
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,7 +1022,7 @@ useEditorGuards(dirty, save);
|
|||||||
.task-sidebar {
|
.task-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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1081,13 +1035,13 @@ useEditorGuards(dirty, save);
|
|||||||
margin: 0.5rem 0 0.25rem;
|
margin: 0.5rem 0 0.25rem;
|
||||||
}
|
}
|
||||||
.task-goal-label {
|
.task-goal-label {
|
||||||
font-family: var(--font-display, "Fraunces", serif);
|
font-family: var(--fs-font-display);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.task-goal-input {
|
.task-goal-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1097,52 +1051,14 @@ useEditorGuards(dirty, save);
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: var(--color-text, inherit);
|
color: var(--fs-text-primary);
|
||||||
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.08));
|
border: 1px solid var(--fs-border-color);
|
||||||
border-radius: var(--radius-md, 8px);
|
border-radius: var(--fs-radius-lg);
|
||||||
}
|
}
|
||||||
.task-goal-input:focus {
|
.task-goal-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary, #6366f1);
|
border-color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Auto-summary banner + re-consolidate button ─────────────────────────── */
|
|
||||||
.auto-summary-banner-editor {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.45rem 0.7rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.6));
|
|
||||||
background: rgba(99, 102, 241, 0.06);
|
|
||||||
border-left: 2px solid var(--color-primary, #6366f1);
|
|
||||||
border-radius: var(--radius-sm, 4px);
|
|
||||||
}
|
|
||||||
.auto-summary-banner-editor .auto-summary-icon {
|
|
||||||
color: var(--color-primary, #6366f1);
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
.btn-reconsolidate {
|
|
||||||
margin-left: auto;
|
|
||||||
padding: 0.25rem 0.7rem;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-style: normal;
|
|
||||||
color: inherit;
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
|
||||||
border-radius: 999px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 120ms ease;
|
|
||||||
}
|
|
||||||
.btn-reconsolidate:hover:not(:disabled) {
|
|
||||||
background: rgba(99, 102, 241, 0.12);
|
|
||||||
border-color: var(--color-primary, #6366f1);
|
|
||||||
}
|
|
||||||
.btn-reconsolidate:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: progress;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -263,29 +263,29 @@ const subTaskProgress = computed(() => {
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<router-link
|
<router-link
|
||||||
:to="store.currentTask.project_id ? `/projects/${store.currentTask.project_id}` : '/tasks'"
|
:to="store.currentTask.project_id ? `/projects/${store.currentTask.project_id}` : '/tasks'"
|
||||||
class="btn-back"
|
class="btn-ghost"
|
||||||
>{{ store.currentTask.project_id ? "← Project" : "← Tasks" }}</router-link>
|
>{{ store.currentTask.project_id ? "← Project" : "← Tasks" }}</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
:to="`/tasks/${store.currentTask.id}/edit`"
|
:to="`/tasks/${store.currentTask.id}/edit`"
|
||||||
class="btn-edit"
|
class="btn-primary"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</router-link>
|
</router-link>
|
||||||
<button
|
<button
|
||||||
v-if="advanceLabel"
|
v-if="advanceLabel"
|
||||||
class="btn-advance"
|
class="btn-primary"
|
||||||
@click="advanceStatus"
|
@click="advanceStatus"
|
||||||
>
|
>
|
||||||
{{ advanceLabel }}
|
{{ advanceLabel }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn-convert"
|
class="btn-secondary btn-compact"
|
||||||
@click="convertToNote"
|
@click="convertToNote"
|
||||||
:disabled="converting"
|
:disabled="converting"
|
||||||
>
|
>
|
||||||
{{ converting ? "Converting..." : "Convert to Note" }}
|
{{ converting ? "Converting..." : "Convert to Note" }}
|
||||||
</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 task → project → milestone -->
|
<!-- Breadcrumb: parent task → project → milestone -->
|
||||||
@@ -365,13 +365,6 @@ const subTaskProgress = computed(() => {
|
|||||||
<p class="goal-text">{{ store.currentTask.description }}</p>
|
<p class="goal-text">{{ store.currentTask.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="store.currentTask.consolidated_at"
|
|
||||||
class="auto-summary-banner"
|
|
||||||
>
|
|
||||||
<span class="auto-summary-icon" aria-hidden="true">✦</span>
|
|
||||||
Auto-summarized from work logs.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="body prose"
|
class="body prose"
|
||||||
@@ -475,83 +468,6 @@ const subTaskProgress = computed(() => {
|
|||||||
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 + Advance: Moss action-primary — both are "operating the software"
|
|
||||||
workflow actions, not brand moments. */
|
|
||||||
.btn-edit,
|
|
||||||
.btn-advance {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.45rem 1.1rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--color-action-primary);
|
|
||||||
color: #fff;
|
|
||||||
text-decoration: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.btn-edit:hover,
|
|
||||||
.btn-advance:hover {
|
|
||||||
background: var(--color-action-primary-hover);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
/* Convert + Share: Bronze action-secondary — alternate paths */
|
|
||||||
.btn-convert {
|
|
||||||
margin-left: auto;
|
|
||||||
padding: 0.3rem 0.75rem;
|
|
||||||
background: var(--color-action-secondary);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.btn-convert:hover { background: var(--color-action-secondary-hover); }
|
|
||||||
.btn-convert:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-share {
|
|
||||||
padding: 0.3rem 0.75rem;
|
|
||||||
background: var(--color-action-secondary);
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.btn-share:hover { background: var(--color-action-secondary-hover); }
|
|
||||||
|
|
||||||
.task-title {
|
|
||||||
font-family: "Fraunces", Georgia, serif;
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.2;
|
|
||||||
margin: 0.25rem 0 0.5rem;
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -559,7 +475,7 @@ const subTaskProgress = computed(() => {
|
|||||||
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 {
|
||||||
@@ -578,10 +494,10 @@ const subTaskProgress = computed(() => {
|
|||||||
}
|
}
|
||||||
.due-date {
|
.due-date {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
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: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.task-meta-row {
|
.task-meta-row {
|
||||||
@@ -592,10 +508,10 @@ const subTaskProgress = computed(() => {
|
|||||||
}
|
}
|
||||||
.task-meta-item {
|
.task-meta-item {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.task-meta-recurrence {
|
.task-meta-recurrence {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.tags {
|
.tags {
|
||||||
@@ -608,7 +524,7 @@ const subTaskProgress = computed(() => {
|
|||||||
/* Sub-tasks */
|
/* Sub-tasks */
|
||||||
.subtasks {
|
.subtasks {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--fs-border-color);
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
}
|
}
|
||||||
.subtasks-header {
|
.subtasks-header {
|
||||||
@@ -624,21 +540,21 @@ const subTaskProgress = computed(() => {
|
|||||||
}
|
}
|
||||||
.subtasks-progress {
|
.subtasks-progress {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.subtasks-pct {
|
.subtasks-pct {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.subtasks-track {
|
.subtasks-track {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.subtasks-fill {
|
.subtasks-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--color-status-done, #22c55e);
|
background: var(--fs-status-done);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
@@ -655,10 +571,10 @@ const subTaskProgress = computed(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.3rem 0.5rem;
|
padding: 0.3rem 0.5rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
}
|
}
|
||||||
.subtask-row:hover {
|
.subtask-row:hover {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
}
|
}
|
||||||
.sub-dot {
|
.sub-dot {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -676,21 +592,21 @@ const subTaskProgress = computed(() => {
|
|||||||
}
|
}
|
||||||
.dot-todo {
|
.dot-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-text-muted, #6b7280);
|
background: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.sub-title {
|
.sub-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -698,21 +614,21 @@ const subTaskProgress = computed(() => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.sub-title:hover {
|
.sub-title:hover {
|
||||||
color: var(--color-primary);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
.sub-title.sub-done {
|
.sub-title.sub-done {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
.sub-due {
|
.sub-due {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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 {
|
||||||
@@ -723,14 +639,14 @@ const subTaskProgress = computed(() => {
|
|||||||
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;
|
||||||
@@ -745,18 +661,18 @@ const subTaskProgress = computed(() => {
|
|||||||
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;
|
||||||
@@ -768,9 +684,9 @@ const subTaskProgress = computed(() => {
|
|||||||
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);
|
||||||
@@ -800,12 +716,12 @@ const subTaskProgress = computed(() => {
|
|||||||
.skel-meta,
|
.skel-meta,
|
||||||
.skel-badges,
|
.skel-badges,
|
||||||
.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;
|
||||||
@@ -817,7 +733,7 @@ const subTaskProgress = computed(() => {
|
|||||||
}
|
}
|
||||||
.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: 65%; border-radius: var(--radius-md); }
|
.skel-title { height: 2.2rem; width: 65%; border-radius: var(--fs-radius-lg); }
|
||||||
.skel-meta { height: 0.85rem; width: 45%; }
|
.skel-meta { height: 0.85rem; width: 45%; }
|
||||||
.skel-badges { height: 1.6rem; width: 30%; border-radius: 999px; }
|
.skel-badges { height: 1.6rem; width: 30%; border-radius: 999px; }
|
||||||
.skel-line { height: 0.9rem; }
|
.skel-line { height: 0.9rem; }
|
||||||
@@ -826,39 +742,26 @@ const subTaskProgress = computed(() => {
|
|||||||
|
|
||||||
/* ── Goal block + auto-summary banner ─────────────────────────────────────── */
|
/* ── Goal block + auto-summary banner ─────────────────────────────────────── */
|
||||||
.task-goal-display {
|
.task-goal-display {
|
||||||
border-left: 2px solid var(--color-border, rgba(255, 255, 255, 0.12));
|
border-left: 2px solid var(--fs-border-color);
|
||||||
padding: 0.4rem 0 0.4rem 0.9rem;
|
padding: 0.4rem 0 0.4rem 0.9rem;
|
||||||
margin: 0.75rem 0 1.25rem;
|
margin: 0.75rem 0 1.25rem;
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
.goal-label {
|
.goal-label {
|
||||||
font-family: var(--font-display, "Fraunces", serif);
|
font-family: var(--fs-font-display);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.5));
|
color: var(--fs-text-tertiary);
|
||||||
margin: 0 0 0.25rem;
|
margin: 0 0 0.25rem;
|
||||||
}
|
}
|
||||||
.goal-text {
|
.goal-text {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
color: var(--color-text, inherit);
|
color: var(--fs-text-primary);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
.auto-summary-banner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--color-text-muted, rgba(255, 255, 255, 0.55));
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
}
|
|
||||||
.auto-summary-icon {
|
|
||||||
color: var(--color-primary, #6366f1);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ onMounted(() => store.fetchTrash());
|
|||||||
<h1>Trash</h1>
|
<h1>Trash</h1>
|
||||||
<button
|
<button
|
||||||
v-if="store.batches.length"
|
v-if="store.batches.length"
|
||||||
class="btn-empty"
|
class="btn-ghost btn-compact"
|
||||||
@click="empty"
|
@click="empty"
|
||||||
>Empty trash</button>
|
>Empty trash</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -52,8 +52,8 @@ onMounted(() => store.fetchTrash());
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="batch-actions">
|
<div class="batch-actions">
|
||||||
<button class="btn-restore" @click="store.restore(b.batch_id)">Restore</button>
|
<button class="btn-ghost btn-compact btn-restore" @click="store.restore(b.batch_id)">Restore</button>
|
||||||
<button class="btn-purge" @click="purge(b.batch_id)">Delete permanently</button>
|
<button class="btn-ghost btn-compact btn-purge" @click="purge(b.batch_id)">Delete permanently</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -64,25 +64,11 @@ onMounted(() => store.fetchTrash());
|
|||||||
.trash-page { max-width: 900px; margin: 0 auto; padding: 1.5rem; }
|
.trash-page { max-width: 900px; margin: 0 auto; padding: 1.5rem; }
|
||||||
.trash-header { display: flex; align-items: center; justify-content: space-between; }
|
.trash-header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
.trash-header h1 { font-family: Fraunces, serif; font-style: italic; margin: 0; }
|
.trash-header h1 { font-family: Fraunces, serif; font-style: italic; margin: 0; }
|
||||||
.btn-empty {
|
|
||||||
background: none; border: 1px solid var(--color-border, #2a2a2e);
|
|
||||||
color: inherit; border-radius: 6px; padding: 0.4rem 0.8rem; cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn-empty:hover { border-color: var(--color-danger, #ef4444); color: var(--color-danger, #ef4444); }
|
|
||||||
.trash-note { opacity: 0.7; font-size: 0.9em; margin: 0.5rem 0 1.5rem; }
|
|
||||||
.trash-loading, .trash-empty { opacity: 0.6; font-style: italic; padding: 2rem 0; }
|
|
||||||
.batch-list { list-style: none; padding: 0; margin: 0; }
|
|
||||||
.batch {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
gap: 1rem; padding: 0.85rem 1rem; margin-bottom: 0.5rem;
|
|
||||||
background: var(--color-surface, #18181b); border-radius: 8px;
|
|
||||||
border-left: 2px solid var(--color-border, #2a2a2e);
|
|
||||||
}
|
|
||||||
.batch-summary { font-weight: 500; }
|
.batch-summary { font-weight: 500; }
|
||||||
.batch-count { opacity: 0.6; font-weight: 400; font-size: 0.9em; margin-left: 0.35rem; }
|
.batch-count { opacity: 0.6; font-weight: 400; font-size: 0.9em; margin-left: 0.35rem; }
|
||||||
.batch-meta { font-size: 0.82em; opacity: 0.6; margin-top: 0.25rem; }
|
.batch-meta { font-size: 0.82em; opacity: 0.6; margin-top: 0.25rem; }
|
||||||
.batch-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
|
.batch-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
|
||||||
.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--color-border, #2a2a2e); background: none; color: inherit; }
|
.batch-actions button { border-radius: 6px; padding: 0.35rem 0.7rem; cursor: pointer; border: 1px solid var(--fs-border-color); background: none; color: inherit; }
|
||||||
.btn-restore:hover { border-color: var(--color-primary, #6366f1); color: var(--color-primary, #6366f1); }
|
.btn-restore:hover { border-color: var(--fs-action-primary); color: var(--fs-action-primary); }
|
||||||
.btn-purge:hover { border-color: var(--color-danger, #ef4444); color: var(--color-danger, #ef4444); }
|
.btn-purge:hover { border-color: var(--fs-action-destructive); color: var(--fs-action-destructive); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
import { apiGet, apiPost, apiPut, apiDelete } from "@/api/client";
|
import { apiGet, apiPost, apiPut, apiDelete, apiErrorMessage } from "@/api/client";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
import type { User } from "@/types/auth";
|
import type { User } from "@/types/auth";
|
||||||
|
import { fmtDate } from "@/utils/dateFormat";
|
||||||
|
|
||||||
interface Invitation {
|
interface Invitation {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -69,12 +70,7 @@ async function sendInvite() {
|
|||||||
inviteEmail.value = "";
|
inviteEmail.value = "";
|
||||||
await fetchInvitations();
|
await fetchInvitations();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e && typeof e === "object" && "body" in e) {
|
toastStore.show(apiErrorMessage(e, "Failed to send invitation"), "error");
|
||||||
const body = (e as { body?: { error?: string } }).body;
|
|
||||||
toastStore.show(body?.error || "Failed to send invitation", "error");
|
|
||||||
} else {
|
|
||||||
toastStore.show("Failed to send invitation", "error");
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
sendingInvite.value = false;
|
sendingInvite.value = false;
|
||||||
}
|
}
|
||||||
@@ -128,24 +124,11 @@ async function deleteUser(userId: number) {
|
|||||||
users.value = users.value.filter((u) => u.id !== userId);
|
users.value = users.value.filter((u) => u.id !== userId);
|
||||||
toastStore.show("User deleted");
|
toastStore.show("User deleted");
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e && typeof e === "object" && "body" in e) {
|
toastStore.show(apiErrorMessage(e, "Failed to delete user"), "error");
|
||||||
const body = (e as { body?: { error?: string } }).body;
|
|
||||||
toastStore.show(body?.error || "Failed to delete user", "error");
|
|
||||||
} else {
|
|
||||||
toastStore.show("Failed to delete user", "error");
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = null;
|
deleting.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
return new Date(iso).toLocaleDateString(undefined, {
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -167,7 +150,7 @@ function formatDate(iso: string): string {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="btn-toggle"
|
class="btn-primary btn-toggle"
|
||||||
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
|
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
|
||||||
@click="toggleRegistration"
|
@click="toggleRegistration"
|
||||||
:disabled="toggling"
|
:disabled="toggling"
|
||||||
@@ -190,7 +173,7 @@ function formatDate(iso: string): string {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn-invite"
|
class="btn-primary"
|
||||||
:disabled="sendingInvite || !inviteEmail.trim()"
|
:disabled="sendingInvite || !inviteEmail.trim()"
|
||||||
>
|
>
|
||||||
{{ sendingInvite ? "Sending..." : "Send Invite" }}
|
{{ sendingInvite ? "Sending..." : "Send Invite" }}
|
||||||
@@ -212,11 +195,11 @@ function formatDate(iso: string): string {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="inv in invitations" :key="inv.id">
|
<tr v-for="inv in invitations" :key="inv.id">
|
||||||
<td class="cell-email">{{ inv.email }}</td>
|
<td class="cell-email">{{ inv.email }}</td>
|
||||||
<td class="hide-mobile cell-date">{{ formatDate(inv.created_at) }}</td>
|
<td class="hide-mobile cell-date">{{ fmtDate(inv.created_at) }}</td>
|
||||||
<td class="hide-mobile cell-date">{{ formatDate(inv.expires_at) }}</td>
|
<td class="hide-mobile cell-date">{{ fmtDate(inv.expires_at) }}</td>
|
||||||
<td class="cell-actions">
|
<td class="cell-actions">
|
||||||
<button
|
<button
|
||||||
class="btn-delete"
|
class="btn-ghost btn-compact"
|
||||||
@click="revokeInvitation(inv.id)"
|
@click="revokeInvitation(inv.id)"
|
||||||
:disabled="revokingId !== null"
|
:disabled="revokingId !== null"
|
||||||
>
|
>
|
||||||
@@ -255,24 +238,24 @@ function formatDate(iso: string): string {
|
|||||||
{{ u.role }}
|
{{ u.role }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="hide-mobile cell-date">{{ formatDate(u.created_at) }}</td>
|
<td class="hide-mobile cell-date">{{ fmtDate(u.created_at) }}</td>
|
||||||
<td class="cell-actions">
|
<td class="cell-actions">
|
||||||
<template v-if="u.id === authStore.user?.id">
|
<template v-if="u.id === authStore.user?.id">
|
||||||
<span class="you-label">You</span>
|
<span class="you-label">You</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="confirmDeleteId === u.id">
|
<template v-else-if="confirmDeleteId === u.id">
|
||||||
<button
|
<button
|
||||||
class="btn-confirm-delete"
|
class="btn-danger btn-compact"
|
||||||
@click="confirmDelete(u.id)"
|
@click="confirmDelete(u.id)"
|
||||||
:disabled="deleting !== null"
|
:disabled="deleting !== null"
|
||||||
>
|
>
|
||||||
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
|
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-cancel-delete" @click="cancelDelete">Cancel</button>
|
<button class="btn-ghost btn-compact" @click="cancelDelete">Cancel</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<button
|
<button
|
||||||
class="btn-delete"
|
class="btn-ghost btn-compact"
|
||||||
@click="confirmDelete(u.id)"
|
@click="confirmDelete(u.id)"
|
||||||
:disabled="deleting !== null"
|
:disabled="deleting !== null"
|
||||||
>
|
>
|
||||||
@@ -297,9 +280,9 @@ function formatDate(iso: string): string {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -317,34 +300,16 @@ function formatDate(iso: string): string {
|
|||||||
.invite-input {
|
.invite-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
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.95rem;
|
font-size: 0.95rem;
|
||||||
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;
|
||||||
}
|
}
|
||||||
.invite-input:focus {
|
.invite-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--fs-accent);
|
||||||
}
|
|
||||||
.btn-invite {
|
|
||||||
padding: 0.45rem 1rem;
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.btn-invite:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.btn-invite:hover:not(:disabled) {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
.invite-list {
|
.invite-list {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
@@ -352,7 +317,7 @@ function formatDate(iso: string): string {
|
|||||||
.invite-list h3 {
|
.invite-list h3 {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Registration toggle */
|
/* Registration toggle */
|
||||||
@@ -370,51 +335,33 @@ function formatDate(iso: string): string {
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
.text-success {
|
.text-success {
|
||||||
color: var(--color-success);
|
color: var(--fs-success);
|
||||||
}
|
}
|
||||||
.text-muted {
|
.text-muted {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
}
|
||||||
.field-hint {
|
.field-hint {
|
||||||
margin: 0.35rem 0 0;
|
margin: 0.35rem 0 0;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
|
||||||
.btn-toggle {
|
|
||||||
padding: 0.45rem 1rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.btn-toggle:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.btn-toggle-open {
|
|
||||||
background: var(--color-primary);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.btn-toggle-open:hover:not(:disabled) {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
|
/* The one genuine override: 'close registration' must NOT read as the
|
||||||
|
primary action it sits on. Scoped, so it beats the shared variant. */
|
||||||
.btn-toggle-close {
|
.btn-toggle-close {
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
color: var(--color-text);
|
color: var(--fs-text-primary);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--fs-border-color);
|
||||||
}
|
}
|
||||||
.btn-toggle-close:hover:not(:disabled) {
|
.btn-toggle-close:hover:not(:disabled) {
|
||||||
border-color: var(--color-warning);
|
border-color: var(--fs-warning);
|
||||||
color: var(--color-warning);
|
color: var(--fs-warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Users table */
|
/* Users 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;
|
||||||
}
|
}
|
||||||
@@ -428,13 +375,13 @@ function formatDate(iso: string): string {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
.users-table td {
|
.users-table td {
|
||||||
padding: 0.65rem 0.75rem;
|
padding: 0.65rem 0.75rem;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--fs-border-color);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
.users-table tbody tr:last-child td {
|
.users-table tbody tr:last-child td {
|
||||||
@@ -444,10 +391,10 @@ function formatDate(iso: string): string {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.cell-email {
|
.cell-email {
|
||||||
color: var(--color-text-secondary);
|
color: var(--fs-text-secondary);
|
||||||
}
|
}
|
||||||
.cell-date {
|
.cell-date {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.cell-actions {
|
.cell-actions {
|
||||||
@@ -462,69 +409,21 @@ function formatDate(iso: string): string {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
padding: 0.15rem 0.4rem;
|
padding: 0.15rem 0.4rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--fs-radius-sm);
|
||||||
}
|
}
|
||||||
.role-admin {
|
.role-admin {
|
||||||
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);
|
||||||
}
|
}
|
||||||
.role-user {
|
.role-user {
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
background: var(--color-bg-secondary);
|
background: var(--fs-surface-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Action buttons */
|
/* Action buttons */
|
||||||
.you-label {
|
.you-label {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--fs-text-tertiary);
|
||||||
}
|
|
||||||
.btn-delete {
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.btn-delete:hover:not(:disabled) {
|
|
||||||
border-color: var(--color-danger);
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.btn-delete:disabled {
|
|
||||||
opacity: 0.4;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.btn-confirm-delete {
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
background: var(--color-danger);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
.btn-confirm-delete:hover:not(:disabled) {
|
|
||||||
filter: brightness(0.9);
|
|
||||||
}
|
|
||||||
.btn-confirm-delete:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.btn-cancel-delete {
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.btn-cancel-delete:hover {
|
|
||||||
color: var(--color-text);
|
|
||||||
border-color: var(--color-text-muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "scribe",
|
"name": "scribe",
|
||||||
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
"description": "Scribe system-of-record for Claude Code: MCP tools over your notes/tasks/projects/rules, a session-start push channel that surfaces your always-on rules + active-project context, process-skills (writing-plans, systematic-debugging, verification, brainstorming, reusing-code), and your saved Scribe Processes auto-surfaced as skills (/scribe:sync). Replaces superpowers + file-memory with one app-backed plugin.",
|
||||||
"version": "0.1.18",
|
"version": "0.1.37",
|
||||||
"author": { "name": "Bryan Van Deusen" },
|
"author": { "name": "Bryan Van Deusen" },
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"scribe": {
|
"scribe": {
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ On install you'll be asked for:
|
|||||||
(`hooks/scribe_prior_art.sh`) → `GET /api/plugin/prior-art`. Returns
|
(`hooks/scribe_prior_art.sh`) → `GET /api/plugin/prior-art`. Returns
|
||||||
`additionalContext` with **no** permission decision, so it can inform the write
|
`additionalContext` with **no** permission decision, so it can inform the write
|
||||||
but never stop it; silent when nothing is recorded, which is most of the time.
|
but never stop it; silent when nothing is recorded, which is most of the time.
|
||||||
|
Two framings: a REUSE menu (similar/nearby records), and a SYNC nudge when a
|
||||||
|
snippet records the exact file being edited — "updating the record is part of
|
||||||
|
the edit" — each with its own once-per-session dedup.
|
||||||
Toggle in **Settings → Knowledge auto-inject**.
|
Toggle in **Settings → Knowledge auto-inject**.
|
||||||
- `skills/` → the universal process-skills, surfaced by description match.
|
- `skills/` → the universal process-skills, surfaced by description match.
|
||||||
- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync`
|
- `hooks/scribe_sync_processes.sh` (a 2nd SessionStart hook) + the `/scribe:sync`
|
||||||
|
|||||||
@@ -11,9 +11,12 @@
|
|||||||
# static floor here. If the instance is unconfigured/unreachable, or anything
|
# static floor here. If the instance is unconfigured/unreachable, or anything
|
||||||
# fails, the hook stays SILENT and exits 0 — it must never block a prompt.
|
# fails, the hook stays SILENT and exits 0 — it must never block a prompt.
|
||||||
#
|
#
|
||||||
# Config (same as scribe_session_context.sh), exported to the hook by Claude Code:
|
# Config (same as scribe_session_context.sh), exported to the hook by Claude Code
|
||||||
# CLAUDE_PLUGIN_OPTION_api_endpoint base URL, no trailing slash
|
# with the userConfig key UPPERCASED (see #2198 — reading the lowercase spelling
|
||||||
# CLAUDE_PLUGIN_OPTION_api_token fmcp_ API key (sensitive)
|
# silently disables this hook, and silence is indistinguishable from "nothing
|
||||||
|
# cleared the threshold"):
|
||||||
|
# CLAUDE_PLUGIN_OPTION_API_ENDPOINT base URL, no trailing slash
|
||||||
|
# CLAUDE_PLUGIN_OPTION_API_TOKEN fmcp_ API key (sensitive)
|
||||||
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
# SCRIBE_URL / SCRIBE_TOKEN override for the settings.json dogfooding path.
|
||||||
#
|
#
|
||||||
# Session dedup: each surfaced note id is remembered in a per-session file so a
|
# Session dedup: each surfaced note id is remembered in a per-session file so a
|
||||||
@@ -32,8 +35,8 @@ event_cwd=$(printf '%s' "$event" | jq -r '.cwd // empty' 2>/dev/null) || event_c
|
|||||||
# Nothing to retrieve against.
|
# Nothing to retrieve against.
|
||||||
[ -n "$prompt" ] || exit 0
|
[ -n "$prompt" ] || exit 0
|
||||||
|
|
||||||
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_api_endpoint:-}}
|
url=${SCRIBE_URL:-${CLAUDE_PLUGIN_OPTION_API_ENDPOINT:-}}
|
||||||
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_api_token:-}}
|
token=${SCRIBE_TOKEN:-${CLAUDE_PLUGIN_OPTION_API_TOKEN:-}}
|
||||||
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
# Guard against an unexpanded ${...} placeholder arriving as a literal.
|
||||||
case "$url" in *'${'*) url="" ;; esac
|
case "$url" in *'${'*) url="" ;; esac
|
||||||
case "$token" in *'${'*) token="" ;; esac
|
case "$token" in *'${'*) token="" ;; esac
|
||||||
@@ -41,15 +44,23 @@ case "$token" in *'${'*) token="" ;; esac
|
|||||||
[ -n "$url" ] && [ -n "$token" ] || exit 0
|
[ -n "$url" ] && [ -n "$token" ] || exit 0
|
||||||
|
|
||||||
# Cap the query length — a giant prompt makes a giant URL for no extra signal.
|
# Cap the query length — a giant prompt makes a giant URL for no extra signal.
|
||||||
q=$(printf '%s' "$prompt" | cut -c1-2000)
|
# `head -c`, not `cut -c1-2000`: cut is line-oriented and caps EACH LINE, so a
|
||||||
q_enc=$(printf '%s' "$q" | jq -rR '@uri' 2>/dev/null) || exit 0
|
# long multi-line prompt sailed past the budget entirely. Same defect as the
|
||||||
|
# prior-art hook's code cap; this copy was missed when that one was fixed, and
|
||||||
|
# scripts/check_plugin.py caught it.
|
||||||
|
q=$(printf '%s' "$prompt" | head -c 2000)
|
||||||
|
# `-sRr`, not `-rR`: jq -R reads LINE BY LINE, so a multi-line prompt encoded as
|
||||||
|
# several lines joined by raw newlines and the request died. Single-line prompts
|
||||||
|
# worked, which is why this looked healthy — the long, substantial prompts most
|
||||||
|
# worth retrieving against were exactly the ones silently dropped. -s slurps.
|
||||||
|
q_enc=$(printf '%s' "$q" | jq -sRr '@uri' 2>/dev/null) || exit 0
|
||||||
|
|
||||||
# Resolve the working repo's remote so the server can scope to the bound project.
|
# Resolve the working repo's remote so the server can scope to the bound project.
|
||||||
repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
repo_dir=${event_cwd:-${CLAUDE_PROJECT_DIR:-$PWD}}
|
||||||
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
repo=$(git -C "$repo_dir" remote get-url origin 2>/dev/null || true)
|
||||||
repo_q=""
|
repo_q=""
|
||||||
if [ -n "$repo" ]; then
|
if [ -n "$repo" ]; then
|
||||||
enc=$(printf '%s' "$repo" | jq -rR '@uri' 2>/dev/null) || enc=""
|
enc=$(printf '%s' "$repo" | jq -sRr '@uri' 2>/dev/null) || enc=""
|
||||||
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
[ -n "$enc" ] && repo_q="&repo=${enc}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user