Compare commits

..

22 Commits

Author SHA1 Message Date
bvandeusen 88b351a96e Merge pull request 'Release v26.04.28.1 — Design system polish, journal config in Profile, doc cleanup' (#44) from dev into main 2026-04-28 13:12:54 +00:00
bvandeusen bc239119f3 fix: SettingsView TS errors + calendar month/year font
CI typecheck failed: JournalLocation interface requires `address: string`,
but Profile-tab Locations code created defaults like `{ label: 'Home' }`
without it. Symptoms were six TS2741 errors in SettingsView.vue.

Fix: include `address` in defaults and treat the user's place-name input
as the address field. Now homeQuery / workQuery sync to
locations.{home|work}.address — both at load time and on geocode.
loadJournalConfig now reads address (was reading label, which is fixed
to "Home"/"Work"); geocodeFor writes the typed query into address while
also setting lat/lon on success.

Calendar Month/Year title was actually rendering Fraunces, not Inter as
I'd claimed. FullCalendar renders .fc-toolbar-title as an <h2>, which
the global theme.css `h1, h2 { font-family: 'Fraunces' }` rule catches
before the parent .fc font-family inherit can do anything. At 1.1rem
it's below the doc's "Fraunces only at ≥18px" threshold, so explicit
Inter override on the deep selector.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 07:58:15 -04:00
bvandeusen 30dfbce426 fix(typography): strip chrome italic; reserve italic for emphasis
Per the design rule "italic is for emphasis, not for design", removed
every chrome `font-style: italic` declaration across the frontend.
42 declarations gone across 24 files: empty-state placeholder copy,
loading messages, "no results" hints, ghost text, voice/role labels,
field placeholder text, brand wordmark, et al.

Markdown content emphasis is unaffected — `<em>` and `<i>` tags from
`*emphasis*` markup still render italic via browser default styling
(prose.css doesn't override em behavior). User-typed emphasis in
notes, journal entries, and chat messages keeps its italic.

Specific spots that lost the decorative italic:

- KnowledgeView .filter-label, .empty-narrator
- NoteEditorView .ef-label, link-suggest related
- ChatPanel .empty-msg, .empty-greeting, .role-label
- ChatMessage .role-assistant .role-label (the "Fable" voice tag —
  was italic per the doc's Illuminated Transcript spec, but per the
  new typography rule the speaker tag stays regular and lets the
  border-left + glow do the bubble framing on their own)
- AppHeader .brand-text ("Fabled" wordmark)
- editor-shared.css .title-input::placeholder
- ProjectView .project-title-input::placeholder
- HomeView .urgency-loading
- WorkspaceTaskPanel .empty-group
- WeatherCard .weather-unavailable
- SharedWithMeView .empty-msg
- DiffView empty-state spans
- ToolCallCard .tool-event-more
- SettingsView 7 spots (.you-label, .geo-pending, hint text, etc.)
- + several other empty-state / hint text spots

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 07:54:26 -04:00
bvandeusen 65ba1cc82a fix(typography): bump italic-Fraunces section/field labels to readable size
KnowledgeView's "Type" / "Tags" filter section headers (.filter-label)
and NoteEditorView's entity-field labels (.ef-label — Email, Phone,
Address etc on person/place notes) were using italic Fraunces accent
at 0.72rem / 0.78rem. The italic + small + decorative-serif combo
read as illegible flourish rather than functional section headers.

Bumped:
- .filter-label 0.72rem → 0.95rem, margin-bottom 6px → 8px
- .ef-label 0.78rem → 0.92rem

Italic Fraunces accent preserved (keeps the branded character that
matches the rest of the surface). Just enlarged to a readable size.

ChatMessage's .role-assistant .role-label kept at 0.8rem italic
Fraunces — that's the doc's Illuminated Transcript voice label, a
decorative speaker tag rather than a functional heading.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 07:42:05 -04:00
bvandeusen 4cfea784a9 fix(projects): tab counter NaN when a status bucket is empty
services/projects.py:get_project_summary built task_counts dynamically
from a GROUP BY query, so a project with no done tasks would omit the
'done' key entirely. Frontend's TypeScript interface declares all three
lifecycle keys as required, and ProjectView.vue summed them to render
the Tasks tab counter — undefined + N = NaN.

Two fixes:
1. Backend: initialise task_counts with {todo: 0, in_progress: 0,
   done: 0} so the service returns the contract its consumers expect.
   Catches the same problem for HomeView's project widget and any
   other consumer.
2. Frontend: defensive ?? 0 on the tab-counter sum, so the existing
   deploy renders correctly even before the backend rolls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 07:40:21 -04:00
bvandeusen 016a2bd270 docs: clear briefing remnants and document journal system
The Briefing system was retired weeks ago and replaced with the
conversational Journal, but several current-state docs still
described it as live. Updates:

architecture.md
- settings keys: briefing_enabled / briefing_locations / office_days →
  journal_config (JSON: locations, temp_unit, prep schedule)
- conversation_type: chat/briefing/mcp → chat/journal/mcp
- briefing_date column → day_date on Conversation
- "Briefing-Related Tables" section: dropped rss_feeds + rss_items
  rows (retired with PR #43); kept weather_cache and clarified that
  lat/lon now live in journal_config.locations, not on the cache row
- routes/chat.py description: dropped trailing "briefing conversation
  routes" mention
- routes/briefing.py → routes/journal.py with the current endpoint
  shape (config / today / day / days / weather / moments / trigger-
  prep)
- services/briefing_pipeline + scheduler + conversations + profile →
  services/journal_prep + journal_pipeline + journal_scheduler +
  journal_search/moments + user_profile.build_profile_context

features.md
- "Daily Briefing" section rewritten as "Daily Journal":
  conversational, single morning prep, right rail with weather +
  upcoming events, profile-tab configuration, "what the assistant
  has learned" framing
- Settings table: dropped Briefing tab row, added Profile tab row,
  fixed Notifications row's stale briefing-push reference

api-reference.md
- /api/briefing/* endpoints replaced with /api/journal/* — config,
  today/day/days, trigger-prep, weather (cached/current/refresh/
  geocode), moments

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 00:11:43 -04:00
bvandeusen 39d56cfcf3 feat(settings): re-add journal config to Profile tab; clear briefing remnants
The Briefing Settings section was removed during the briefing→journal
migration but its data (locations, temp unit, prep schedule) is still
read by the journal backend. There was no UI to set any of it, so
weather couldn't render and prep timing wasn't tunable.

Re-adds the missing config inside the existing Profile tab — it's all
"about the user" data and a separate Journal tab would just clutter
the sidebar. New sections:

Locations (after Work Schedule)
- Home and Work place-name inputs with on-blur geocoding via
  /api/journal/weather/geocode
- Temperature unit toggle (Celsius / Fahrenheit)
- Status messages distinguish ok / pending / error

Journal (before What the Assistant Has Learned)
- Daily prep auto-generate toggle
- Prep generation hour:minute (24-hour input)
- Day rollover hour (so 1–3am entries still count as the previous day)
- All controls disable cleanly when prep is off

Cleanup
- Profile "About You" desc: "chat and briefings" → "chat and the daily journal"
- Profile "Interests" desc: "personalise news and briefing context" →
  "personalise the journal's daily prep and chat responses"
- Profile "Work Schedule" desc: "Helps the briefing" → "Helps the journal"
- Profile "What the Assistant Has Learned" desc: clarifies the summary
  is included in the journal's system prompt; observations come from
  journal + chat (not briefing)
- General "Timezone" desc: "schedule briefings" → "schedule the daily
  journal prep"
- Removed the dead `.briefing-*` CSS block (~190 lines of styles for
  retired briefing UI: feed-row, slot-row, add-feed-form, etc.) and
  replaced with fresh `.location-row`, `.unit-toggle`, `.unit-btn`,
  `.checkbox-label`, `.time-row`, `.time-input`, `.geo-msg` rules used
  by the new sections. unit-btn.active uses Moss action-primary per
  Hybrid; tokens flow through the rest.

The "What the Assistant Has Learned" section was confirmed
load-bearing for the journal — `journal_pipeline.py:139` calls
`build_profile_context()` which feeds learned_summary plus other
profile fields into the journal's system prompt. Not a remnant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 00:05:07 -04:00
bvandeusen 3f287d7417 fix(design): brighten status-dot indicator lights
The "ready" / "cold" / "unavailable" dots in the AppHeader status
indicator were pulling --color-success / --color-warning /
--color-danger which after the foundation pass became Moss /
Warning gold-brown / Error terracotta — all visibly muted. The
green-ready dot in particular read too dark to register as a
"ready light", and the pulse glow (bright emerald) had nothing to
glow off of.

Status dots are indicator *lights*, not semantic-palette UI
elements. Decouple them with hardcoded vital values:
- ready: #4ade80 (matches the existing pulse glow)
- cold:  #facc15
- red:   #ef4444
Orange already used a hardcoded bright #f97316; left as-is.

The rest of the system continues to use --color-success /
--color-warning / --color-danger semantically (toast-success,
validation errors, etc.) — only the indicator-light contexts get
the brighter palette.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:33:33 -04:00
bvandeusen 64ab24864a docs(design-system): mark surface phase shipped end-to-end
Updates the Scribe-specific decisions section header and replaces
the Surface-Phase TODO checklist with a per-PR ship table covering
the seven PRs that landed today (93a3beb3c1ec40). Adds an
explicit "Out of scope — deferred indefinitely" subsection so
future-me knows what's intentionally not done (stroke-weight
overrides, filled-as-active state, type-scale tokens, etc.).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:23:36 -04:00
bvandeusen 3c1ec4077f feat(design): surface phase PR 7 — edge surfaces polish
Per the spec, the final per-surface PR bundles the smaller / chrome-y /
library-driven views (Header / Home / Calendar / Graph) together with
EventSlideOver since it's the calendar's primary interaction surface.

Button reclassification per Hybrid rule
- CalendarView btn-new-event: accent gradient → Moss action-primary.
  Creating an event is a workflow action, not a brand moment.
- EventSlideOver btn-primary (Save): accent gradient → Moss
- EventSlideOver btn-secondary (Cancel/No): muted ghost → Bronze
  action-secondary
- EventSlideOver btn-danger-ghost (Delete): hardcoded #ef4444 →
  --color-action-destructive ghost (with filled hover)
- EventSlideOver btn-danger (confirm Delete): hardcoded #ef4444 →
  --color-action-destructive filled

Brand-moment CTAs kept on accent
- HomeView btn-workspace-hero: home-page hero CTA into the active
  project — central brand-moment surface, gradient stays
- HomeView btn-workspace-sm: per-card workspace icon with the
  accent-tinted bg + hover-to-accent pattern, kept

Two-weights-only
- Snapped every font-weight 600/700 to 500 across the bundle
  (HomeView, GraphView, CalendarView, AppHeader, EventSlideOver).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:17:04 -04:00
bvandeusen 541e2ed713 feat(design): surface phase PR 6 — Settings polish
Settings is the densest button surface in the app. Per the surface-
phase spec, classify every button on a deliberate token.

Button reclassification per Hybrid rule
- btn-save (~13 instances across General/Account/Notifications/
  Integrations/Data/Briefing/Admin tabs): flat accent → Moss
  action-primary
- btn-primary (Groups tab "Save Retention", "+ New Group"): flat
  accent → Moss action-primary
- btn-secondary (Detect timezone, Test SMTP, Refresh, Export, Add
  slot, Preview voice, etc.): outline-on-bg-secondary +
  hover-to-accent → Bronze action-secondary, filled
- btn-danger filled (Reset VAPID, Revoke API key Yes): added the
  rule (was previously unstyled — relied on no-rule). Now Oxblood
  action-destructive.
- btn-danger-outline (Invalidate sessions, Clear observations,
  Delete personal data): generic --color-danger → Oxblood
  action-destructive
- btn-danger-sm hover (group + member delete): --color-danger →
  --color-action-destructive
- btn-delete row hover, btn-confirm-delete (user delete two-stage):
  --color-danger → Oxblood. Confirm filled, cancel ghost.
- btn-toggle-open ("Open registration"): flat accent → Moss
- model-delete-btn hover: --color-danger → Oxblood
- btn-warn hover: muted-border-warning → filled-warning

Two-weights-only
- Snapped every font-weight 600/700 to 500.

Out of scope for this PR
- Voice/tone copy audit across labels and help text — per spec,
  opportunistic only. Skipped.
- Settings row de-bordering — current section dividers are
  structural; left as-is.
- Validation-state colors (.input-error etc.) keep --color-danger
  (Error terracotta) per the doc — Error is for validation/error
  messages, Oxblood is for destructive actions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:13:38 -04:00
bvandeusen ff498ce1a4 feat(design): surface phase PR 5 — Project + Workspace polish
Per the surface-phase spec for the Project list, Project view, and
3-panel Workspace cluster:

Button reclassification per Hybrid rule
- ProjectView:
  - btn-workspace ("Open Workspace"): kept on accent gradient — the
    workspace is the project's central feature moment, a Scribe-
    flavored action, brand moment per the doc.
  - btn-share: muted-outline → Bronze action-secondary
  - btn-danger-outline (Delete project): generic-border + danger-on-
    hover → Oxblood outline + filled hover (proper destructive)
  - btn-save-panel: accent gradient → Moss action-primary
  - btn-ms-confirm (milestone confirm): flat accent → Moss
  - btn-ms-cancel: ghost → Bronze action-secondary
  - .modal-btn-danger: --color-danger → --color-action-destructive
- ProjectListView:
  - btn-primary ("+ New Project"): flat accent → Moss action-primary.
    Empty-state CTA (.empty-action) keeps accent — that *is* a
    brand moment per Hybrid, "create your first project".
- WorkspaceTaskPanel + WorkspaceNoteEditor:
  - btn-delete-task / btn-delete: hover --color-danger → Oxblood
  - btn-delete-confirm / btn-confirm-delete: --color-danger pair →
    --color-action-destructive pair, with filled hover for stronger
    affordance
  - btn-save (WorkspaceNoteEditor): flat accent → Moss
- WorkspaceChatWidget:
  - History toggle: replaced unicode ▾ with Lucide ChevronDown
    (PR 1 carry-over for emoji-as-icon)

Two-weights-only
- Snapped every font-weight 600/700 to 500 across the cluster.

Borders kept (per structural-not-decorative rule)
- Milestone group cards: standalone containers, keep
- Project cards in list: standalone containers, keep
- Task slide-over panel: drawer modal, keep
- Workspace 3-panel separators: major section dividers, keep

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:09:33 -04:00
bvandeusen efb3534f3a feat(design): surface phase PR 4 — Knowledge cluster polish
Per the surface-phase spec for the Notes/Tasks viewers and editors:

Long-form line-height
- prose.css: bumped global .prose from 1.6 to 1.7. Applies to Note
  viewer body, Task viewer body, anywhere markdown renders into a
  reading surface. Chat assistant bubble already had the explicit
  override; now consistent with the rest.

Button reclassification per Hybrid rule
- Shared editor-shared.css:
  - btn-save: accent gradient → Moss (action-primary). Saving is
    "operating the software", not a brand moment.
  - btn-delete: --color-danger (Error terracotta) → Oxblood
    (action-destructive). Layout updated for inline-flex so the
    Trash2 icon at call sites lines up alongside the label.
- NoteEditorView, TaskEditorView: Delete buttons now contain a
  Trash2 icon per Hybrid's "destructive paired with icon" rule.
- NoteViewerView, TaskViewerView:
  - btn-edit (and TaskViewer's btn-advance): accent gradient → Moss.
    Switching to edit / advancing status are workflow actions.
  - btn-convert, btn-share: ghost-on-hover-to-accent → Bronze
    action-secondary (alternate paths).

Two-weights-only
- Snapped every font-weight: 600/700 to 500 across editor-shared.css
  and the five Knowledge-cluster views.

Out of scope for this PR (deliberate punt)
- Smaller utility buttons (.btn-suggest-tags, .btn-link-all,
  .btn-add-subtask, the AI-assist generate/proofread/accept/reject
  set, etc.) — currently ghost-styled, generally compliant. Will
  revisit only if they read off in practice.
- Filter chip / sub-task list-row border audit — deferred since
  current styles already lean on background tint for affordance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:02:59 -04:00
bvandeusen 4192a64c0f feat(design): surface phase PR 3 — Chat surface polish
Per the surface-phase spec for the Chat cluster (ChatView, ChatPanel,
ChatMessage, ChatInputBar, ToolCallCard):

Long-form line-height
- ChatMessage: assistant-bubble .message-content jumps from 1.55 to
  1.7 — chat is a reading surface, not a snippet stream. User
  bubbles stay tighter.
- JournalView: dropped its :deep(.role-assistant .message-content)
  override; ChatMessage handles it now and Journal inherits.

ToolCallCard borders
- Removed the outer 1px border. ToolCallCard always renders inside
  an assistant bubble; the bubble already contains it. Background
  tint differentiates without re-bordering. Per the
  structural-not-decorative rule.
- Error state preserved as a 3px left-edge accent in --color-danger,
  mirroring the assistant bubble's own left-edge pattern.

Button reclassification per Hybrid rule
- ChatView .bulk-link "All"/"None": accent text → --color-text-secondary
  (these are tertiary list-control affordances, not brand moments)
- ChatView .bulk-delete-btn: --color-danger (Error terracotta) →
  --color-action-destructive (Oxblood) per Hybrid; paired with a
  Trash2 icon since destructive should always be reinforced by an
  icon, not just color
- ChatView .btn-delete-conv hover: same Error → Oxblood swap
- .btn-new-conv stays accent (brand moment, correct already)
- .btn-send stays accent gradient (primary brand moment, foundation)

Two-weights-only
- Snapped every font-weight: 600/700 to 500 across ChatView,
  ChatPanel, ChatMessage, ToolCallCard per the doc rule.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 22:42:44 -04:00
bvandeusen f20e54a068 fix(journal): widen events window to match prep's framing
The right rail's loadEvents was using "now → now + 14 days", which
filtered out events earlier today that had already passed. The prep
uses "today 00:00 → today 23:59" so it includes those events and
explicitly notes they're "in the past". The two surfaces talked
about different sets of events.

Widen the right rail to "today 00:00 → today + 14 days 23:59" so
events from earlier today still surface and group under "Today". The
prep and the right rail now reference the same set within today.
Events further out (next 14 days) keep working as before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:55:01 -04:00
bvandeusen de4b1d7c7e fix(weather): match prep behavior — serve cached weather regardless of age
The /api/journal/weather route was filtering out cache rows older than 24
hours via parse_weather_card_data, while journal_prep.py read the same
rows raw without freshness checking. Result: the daily prep referenced
"home" and "work" temperatures while the right-rail UI showed nothing —
two surfaces, same backing data, inconsistent visibility.

Two changes:

1. parse_weather_card_data no longer returns None for stale data.
   WeatherCard already exposes fetched_at and gracefully hides
   today_high / forecast fields when they're absent, so old data renders
   with whatever fields the cached forecast still covers.

2. The /weather route opportunistically schedules a background refresh
   for any cache row older than 4 hours. If the user's journal_config
   has lat/lon for that location_key, the refresh runs and the next
   page load gets fresh data; if no usable config, the refresh is a
   silent no-op and the stale cache is still served.

This makes prep and UI consistent. It also self-heals over time — once
locations are configured, stale caches get refreshed on the next page
load instead of waiting indefinitely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:19:57 -04:00
bvandeusen 3d916d7357 feat(design): surface phase PR 2 — Journal polish
Per the surface-phase spec for Journal:

- Weather refresh button: ↻ unicode → Lucide RotateCcw (PR 1
  carry-over for emoji-as-icon)
- .journal-title: dropped redundant font-family override (h1 inherits
  Fraunces from theme.css); also snapped weight 700 → 500 per doc's
  "two weights only" rule
- .weather-tab.active, .events-day-label, .event-title, .panel-label:
  snapped 600/700 → 500 per same rule
- Added long-form 1.7 line-height to journal-chat-panel assistant
  bubble content (the daily prep is prose, not chat snippets)
- Removed dead .news-section / .news-card / .news-* / .reaction-btn
  CSS — news functionality was retired with the briefing→journal
  migration (PR #43); styles were never cleaned up

Buttons audited per Hybrid rule: btn-trigger ("Refresh prep") and
weather-refresh-btn are tertiary actions, already correctly styled
as Pewter ghost. .btn-send (Journal Send) flows through ChatInputBar
on the accent gradient — brand moment, kept as-is.

Borders audited per structural-not-decorative: header bottom,
sidebar left, section dividers, form input borders all kept (all
genuinely structural). No decorative borders to remove.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 20:53:09 -04:00
bvandeusen df423ab906 fix(task-editor): prevent preview pane and work log from flex-shrinking
.task-main is a flex column. Children default to flex-shrink: 1, so
when body content is taller than the available column height, flex
squeezes .preview-pane back to its min-height (200px) and the
overflow renders visibly on top of subsequent siblings — making the
TaskLogSection appear in the middle of the body content.

Surfaced by viewing a task with an unusually long markdown body in
preview mode. Pre-existed PR 1, just rarely hit.

NoteEditorView uses the standard block-flow .editor-main and isn't
affected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 20:45:50 -04:00
bvandeusen d023209f13 chore(deps): regenerate package-lock for lucide-vue-next
CI's npm ci step requires lock and package.json to be in sync.
Adds the lucide-vue-next ^0.469.0 entry plus the indirect
@esbuild platform variants that npm pulls in on lockfile refresh.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 20:05:50 -04:00
bvandeusen 93a3beb5d6 feat(design): surface phase PR 1 — Lucide icon migration + scale enforcement
Replaces every hand-inlined SVG in the chrome (60 across 15 files)
with Lucide components. Snaps icon sizes to the doc's 16/24 scale —
all icons in this pass land at 16. AppLogo wordmark stays as the
legitimate "custom app icon" exception per the doc; GraphView's <svg>
mount point for D3 stays as well.

Replaces emoji-as-icons in clear button-affordance cases with their
Lucide equivalents: ✕ → X (close/dismiss buttons across 8 files), ✓ →
Check (ProjectView task-advance button), 🎤 → Mic (ChatPanel voice
CTA), 📎 → Paperclip (ChatView context toggle), &uarr; → ArrowUp
(ChatInputBar Send), &times; → X (ChatPanel context-note remove), ☀/☾
→ Sun/Moon (AppHeader theme toggle).

Inline emoji punctuation in textual confirmation copy ("Saved ✓",
"Created ✓", `done: "✓"` status maps, `'✓' : '📄'` interpolations)
is left for surface-phase voice/tone touchups — replacing requires
structural template changes and is best done per-component.

Stroke weight stays at Lucide's default 2px; tightening to the doc's
1.5/1 is deferred per the surface-phase spec (option ii: drop-in +
scale enforcement).

Adds lucide-vue-next ^0.469.0 to frontend/package.json. Docker
rebuild handles the install.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 19:56:01 -04:00
bvandeusen 54710b35e9 docs(design-system): note foundation pass shipped; expand surface-phase thread
Updates the "Scribe-specific decisions in progress" header to reflect
that the foundation pass landed in 7a9a8b7. Replaces the brief "next
phase" placeholder with a concrete shipped/remaining split — listing
what foundation covered and the surface-phase work that still needs
doing (button reclassification, Lucide migration, border audit,
voice/tone, long-form line-height, bubble shape tweaks).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 18:05:47 -04:00
bvandeusen 7a9a8b7819 feat(design): foundation pass — dusty violet palette, warm parchment light, Inter + JetBrains Mono
Applies the FabledSword + Scribe iteration palette to theme.css end to
end: indigo (#7c3aed) → dusty violet (#5B4A8A) accent, cool grey →
Obsidian/Iron/Pewter dark surfaces, warm parchment (#F5F1E8) light
mode, Inter body + JetBrains Mono code loaded alongside Fraunces, and
neutral hairline scrollbars (chrome is structural, not branded).

Adds the action token set (--color-action-primary Moss,
--color-action-secondary Bronze, --color-action-destructive Oxblood,
--color-action-ghost-border Pewter) but does not yet reclassify any
buttons — surface-phase work. Buttons remain dusty-violet gradients in
the meantime, by design.

Removes deprecated --color-accent-warm; replaces concrete usages with
--color-text-secondary (dates, flavor copy) or --color-warning (paused
status). Sweeps hardcoded indigo literals in component scoped CSS so
they don't bypass the token system.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 17:20:01 -04:00
48 changed files with 1934 additions and 883 deletions
+13 -14
View File
@@ -145,23 +145,22 @@ All endpoints require login (session cookie or `Authorization: Bearer <api-key>`
|--------|------|-------------|
| GET | `/api/search` | Semantic + keyword search across notes and tasks. Params: `q`, `type` (`note`/`task`/`all`), `limit` |
## Briefing
## Journal
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/briefing/config` | Get briefing configuration |
| PUT | `/api/briefing/config` | Save briefing configuration |
| GET | `/api/briefing/feeds` | List RSS feeds |
| POST | `/api/briefing/feeds` | Add RSS feed `{url, name?, category?}` |
| DELETE | `/api/briefing/feeds/:id` | Delete feed |
| POST | `/api/briefing/feeds/refresh` | Trigger immediate feed refresh → `{feeds_refreshed, new_items}` |
| GET | `/api/briefing/weather` | Get weather configuration |
| PUT | `/api/briefing/weather` | Save weather locations |
| POST | `/api/briefing/weather/geocode` | Geocode address `{query}``{lat, lon, label}` |
| POST | `/api/briefing/trigger` | Manually fire a briefing slot `{slot}` |
| GET | `/api/briefing/conversations` | List past briefing conversations |
| GET | `/api/briefing/conversations/today` | Get/create today's briefing conversation |
| GET | `/api/briefing/conversations/:id/messages` | Get messages for a briefing conversation |
| GET | `/api/journal/config` | Get journal configuration (locations, temp_unit, prep schedule) |
| PUT | `/api/journal/config` | Save journal configuration; live-reschedules the prep job |
| GET | `/api/journal/today` | Get/create today's journal conversation + messages |
| GET | `/api/journal/day/:iso` | Get a specific day's journal conversation (read-only) |
| GET | `/api/journal/days` | List dates with journal content, newest first |
| POST | `/api/journal/trigger-prep` | Force-regenerate today's prep (or `{date}` for a specific day) |
| GET | `/api/journal/weather` | Cached weather rows; auto-refreshes stale rows in the background |
| GET | `/api/journal/weather/current` | Live current conditions for the primary configured location |
| POST | `/api/journal/weather/refresh` | Manual refresh of all configured locations |
| POST | `/api/journal/weather/geocode` | Geocode place name `{query}``{lat, lon, label}` |
| POST | `/api/journal/moments/:id/update` | Update a recorded moment |
| DELETE | `/api/journal/moments/:id` | Delete a moment |
## Settings
+13 -14
View File
@@ -133,11 +133,11 @@ Indexes: GIN on `tags`, B-tree on `status`, B-tree on `title`.
### Settings
Composite PK `(user_id, key)`. Per-user key-value store. CRUD via `services/settings.py`. Used for: `default_model`, `assistant_name`, `briefing_enabled`, `briefing_locations`, `office_days`, etc.
Composite PK `(user_id, key)`. Per-user key-value store. CRUD via `services/settings.py`. Used for: `default_model`, `assistant_name`, `journal_config` (JSON: locations, temp_unit, prep schedule), `user_timezone`, `voice_*`, etc.
### Conversations / Messages
`conversations`: `id`, `title`, `model`, `user_id`, `conversation_type` (`chat`/`briefing`/`mcp`), `briefing_date`, `rag_project_id` (nullable int — RAG scope: NULL=orphan-only, -1=all, positive=project), `created_at`, `updated_at`.
`conversations`: `id`, `title`, `model`, `user_id`, `conversation_type` (`chat`/`journal`/`mcp`), `day_date` (YYYY-MM-DD for journal conversations), `rag_project_id` (nullable int — RAG scope: NULL=orphan-only, -1=all, positive=project), `created_at`, `updated_at`.
`messages`: `id`, `conversation_id` FK CASCADE, `role` (`user`/`assistant`), `content`, `status` (`done`/`generating`), `created_at`.
Title auto-generated by LLM on first exchange, re-generated every 10th message.
@@ -155,11 +155,9 @@ Title auto-generated by LLM on first exchange, re-generated every 10th message.
Permission resolution is centralised in `services/access.py`. `get_project_permission(uid, project_id)` checks ownership → direct share → group-based share → note→project inheritance, returning the highest applicable permission.
### Briefing-Related Tables
### Journal-Related Tables
`rss_feeds`: `id`, `user_id`, `url`, `name`, `category`, `last_fetched_at`.
`rss_items`: `id`, `feed_id` FK, `guid`, `title`, `url`, `summary`, `pub_date`.
`weather_cache`: per-user cache with `lat`, `lon`, `location_name`, `forecast_json`, `fetched_at`.
`weather_cache`: per-user, per-`location_key` cache. Columns: `user_id`, `location_key` (`home`/`work`/etc.), `location_label`, `forecast_json` (Open-Meteo response), `previous_json` (last forecast, used to detect changes), `fetched_at`. Lat/lon are *not* stored on the cache row — they live in the user's `journal_config.locations.{home|work}` setting and are used at refresh time.
### API Keys
@@ -188,14 +186,14 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
| `routes/api.py` | `/api` blueprint; `GET /api/health` (public) |
| `routes/auth.py` | Register, login, logout, me, password/email change, password reset, invite registration, OAuth login+callback; rate limiting; `LOCAL_AUTH_ENABLED` guards |
| `routes/admin.py` | Backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only) |
| `routes/chat.py` | Conversations CRUD; SSE generation stream; model pull/delete/list/warm; briefing conversation routes |
| `routes/chat.py` | Conversations CRUD; SSE generation stream; model pull/delete/list/warm |
| `routes/notes.py` | Notes CRUD + wikilinks + backlinks + assist + link suggestions + version history + drafts |
| `routes/tasks.py` | Tasks CRUD; `POST` accepts `project` name string (resolved to `project_id`) |
| `routes/task_logs.py` | Task work log CRUD (`GET/POST/DELETE /api/tasks/:id/logs`) |
| `routes/projects.py` | Projects CRUD + summary endpoint |
| `routes/milestones.py` | Milestones CRUD under `/api/projects/:id/milestones` |
| `routes/settings.py` | Per-user settings key-value (`GET/PUT /api/settings/:key`) |
| `routes/briefing.py` | Briefing conversation + reply + history; RSS feed management |
| `routes/journal.py` | Journal config CRUD; today/day/days conversation accessors; weather endpoints (cached/current/refresh/geocode); moments CRUD; trigger-prep |
| `routes/groups.py` | Group CRUD + membership management (admin) |
| `routes/shares.py` | Share project/note with user or group; revoke; list incoming shares |
| `routes/in_app_notifications.py` | In-app notification list + mark-read + count |
@@ -227,10 +225,11 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
| `services/groups.py` | Group CRUD; membership management |
| `services/notifications.py` | Create/read/mark-read in-app notifications |
| `services/task_logs.py` | Append/list/delete work log entries on tasks |
| `services/briefing_pipeline.py` | Two-lane parallel gather → LLM synthesis → `GenerationBuffer` stream |
| `services/briefing_scheduler.py` | APScheduler `BackgroundScheduler`; slots with catch-up logic; async-safe via `asyncio.create_task` |
| `services/briefing_conversations.py` | Briefing conversation persistence and history queries |
| `services/briefing_profile.py` | Per-user profile note that the assistant updates over time |
| `services/journal_prep.py` | Deterministic data gather (tasks/events/weather/projects/recent moments/open threads) → LLM prose opener; persisted as the first assistant message of today's journal Conversation |
| `services/journal_pipeline.py` | System-prompt builder for journal conversations; calls `build_profile_context()` so the LLM sees the user's profile + learned summary |
| `services/journal_scheduler.py` | APScheduler `BackgroundScheduler`; per-user prep job; live-reschedule via `update_user_schedule()`; catch-up logic for missed runs |
| `services/journal_search.py`, `services/moments.py` | Moment recording + search across journal history |
| `services/user_profile.py` | `build_profile_context()` consolidates profile + observations + learned_summary into a system-prompt block |
| `services/research.py` | SearXNG research pipeline: sub-queries → parallel fetch → outline → section synthesis → executive summary → index note with linked section notes |
| `services/events.py` | Internal events CRUD: `list_events`, `create_event`, `update_event`, `delete_event`, `get_event`; source of truth for all event LLM tools |
| `routes/events.py` | `/api/events` — event CRUD routes |
@@ -293,8 +292,8 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
| `services/generation_task.py` | SSE streaming, tool-call loop, GenerationBuffer management |
| `services/tools/` | LLM tool implementations (38 tools across 11 modules); decorator-based registry |
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes()` |
| `services/briefing_pipeline.py` | Two-lane parallel gather → LLM synthesis → briefing output |
| `services/briefing_scheduler.py` | APScheduler integration, catch-up logic for missed slots |
| `services/journal_prep.py` | Data gather → LLM prose opener; persisted into today's journal conversation |
| `services/journal_scheduler.py` | APScheduler integration, per-user prep job, catch-up for missed runs |
| `services/backup.py` | Full and per-user backup export/restore (version 2 format) |
| `services/weather.py` | Nominatim geocoding + Open-Meteo forecast fetch + DB cache |
| `services/rss.py` | feedparser-based fetch, per-feed DB cache, prune-to-100 |
+41 -2
View File
@@ -362,7 +362,7 @@ If using Tailwind, extend the theme with these tokens rather than relying on def
> This section tracks decisions made while adapting the FabledSword baseline above for Scribe specifically. Items here are *in progress* — once they feel solid, they get folded into the main body of the document (either as Scribe-specific extensions in the per-app section, or as updates to the universal rules where Scribe's needs reveal a gap in the baseline).
*Iteration started: 2026-04-26.*
*Iteration started: 2026-04-26. Foundation pass shipped 2026-04-27 in `7a9a8b7` (palette, fonts, light mode, action tokens, hardcoded indigo cleanup, warm-gold deprecation). Surface phase shipped 2026-04-27 across `93a3beb` → `3c1ec40` (Lucide migration, Hybrid-rule button reclassification per surface, long-form line-height, two-weights-only). The system is now applied end-to-end; this section will fold into the main body once the result has had time to settle in real use.*
## Decisions made so far
@@ -531,4 +531,43 @@ Border weight is not load-bearing for Scribe — happy to use the doc's 0.5px ha
## Open threads (next iterations)
*All initial-iteration threads resolved. Next phase is the polish pass — applying the system to existing UI, component by component. New threads will accumulate here as we discover gaps in the polish pass.*
### Foundation pass — shipped 2026-04-27 (`7a9a8b7`)
Mechanical token + font + light-mode rewrite of `frontend/src/assets/theme.css`, plus a sweep of hardcoded indigo and `--color-accent-warm` references across ~14 component files. Action tokens (`--color-action-primary` Moss, `--color-action-secondary` Bronze, `--color-action-destructive` Oxblood, `--color-action-ghost-border` Pewter) are defined but not yet applied — buttons still flow through `--color-primary` and read as dusty-violet gradients in the meantime, by design. Spec lives at `docs/superpowers/specs/2026-04-27-design-system-polish-foundation-design.md` (gitignored, local-only).
### Surface phase — shipped 2026-04-27 (`93a3beb` → `3c1ec40`)
Bundled as Hybrid (option C from the brainstorm): Lucide cross-cutting first, then surface-by-surface for the judgment work. Spec lives at `docs/superpowers/specs/2026-04-27-design-system-polish-surface-design.md` (gitignored, local-only). Seven PRs landed on `dev`:
| PR | Commit | Surface | Notes |
|---|---|---|---|
| 1 | `93a3beb` | Lucide cross-cutting | 60 hand-inlined SVGs across 15 files → `lucide-vue-next`. Every chrome icon at 16 or 24. Emoji-as-icons (`✕`, `✓`, `🎤`, `📎`, `↻`, `↑`, `&times;`, `☀`/`☾`) swept across the chrome. AppLogo wordmark and the GraphView D3 mount kept as the legitimate exceptions. |
| 2 | `3d916d7` | Journal | Buttons audited; `↻` weather refresh → Lucide `RotateCcw`; assistant bubble line-height bumped; redundant `.journal-title` font-family dropped; dead `.news-section` CSS removed. |
| 3 | `4192a64` | Chat | `.message-content` long-form 1.7 line-height on assistant bubbles; ToolCallCard outer border removed (bubble already contains it; error state moved to a left-edge accent); bulk-delete recolored to Oxblood with Trash2 icon; `.bulk-link` accent → muted text. |
| 4 | `efb3534` | Knowledge cluster | `.prose` line-height bumped to 1.7 globally so all reading surfaces inherit. Save → Moss; Delete → Oxblood + Trash2; Edit / Advance → Moss; Convert / Share → Bronze. |
| 5 | `ff498ce` | Project + Workspace | Project save panel → Moss; milestone confirm/cancel → Moss/Bronze; modal-btn-danger and per-row delete affordances → Oxblood; "Open Workspace" stays accent (project-surface brand moment). |
| 6 | `541e2ed` | Settings | Densest button surface — every `.btn-save`, `.btn-primary`, `.btn-secondary`, `.btn-danger`, `.btn-danger-outline`, `.btn-toggle-open` reclassified per Hybrid; missing `.btn-danger` style block added (was unstyled before). |
| 7 | `3c1ec40` | Edge surfaces | Calendar `New Event` → Moss; EventSlideOver Save/Cancel/Delete reclassified; HomeView hero CTA stays accent (brand moment); two-weights-only sweep across Header/Home/Calendar/Graph. |
**Cross-cutting changes folded in as the work touched files:**
- Long-form 1.7 line-height on `.prose` (PR 4) — applies to Note viewer, Task viewer, chat assistant bubbles, anywhere markdown renders into a reading surface.
- Two-weights-only (400 + 500) — every `font-weight: 600` and `700` snapped to `500` across all surface PRs.
- Hardcoded `--color-danger` in destructive button contexts → `--color-action-destructive` (Oxblood). `--color-danger` (Error terracotta) preserved for validation/error messages, per the doc's distinction between Error and Destructive.
- Adjacent `&times;` / `✕` / unicode-arrow emoji swept opportunistically as files were touched (PRs 5, 7).
### Out of scope — deferred indefinitely
Items deliberately not addressed in this round; revisit when a real need surfaces:
- Lucide stroke-weight overrides (doc spec: 1.5 at 24, 1 at 16; current: Lucide default 2). Touched components if they read too heavy in practice.
- Filled-as-active icon state — no current affordance uses it; introduce when bookmark/pin/star toggles are added.
- Type-scale / spacing-scale CSS variables — components keep literal values.
- Token rename to `--fs-*` namespace — Scribe is the only FabledSword app sharing this codebase.
- FabledSword lockup placement — waiting on the actual heraldic mark to be drawn.
- Standalone voice/tone audit across every UI string — opportunistic-only; full sweep deferred unless drift becomes visible.
- A handful of editor utility buttons (`.btn-suggest-tags`, `.btn-link-all`, AI assist generate/proofread/accept/reject set, etc.) — currently ghost-styled and visually compliant; revisited only if they read off in practice.
### Open threads
*New threads will accumulate here as gaps surface in real use.*
+12 -9
View File
@@ -52,19 +52,22 @@ Full conversation history with SSE streaming. Features:
- **Bulk delete** — Select and delete multiple conversations.
- **Retention** — Conversations auto-pruned after configurable days (default 90).
## Daily Briefing
## Daily Journal
`/briefing` is a scheduled, dialogue-based morning briefing. The assistant compiles tasks, calendar events, projects, weather forecast, and RSS digest at configurable times, then checks in throughout the day. You can reply interactively.
`/journal` is a conversational daily surface — each day is a chat-style conversation seeded with an LLM-generated daily prep as the first assistant message. The prep pulls together today's tasks, calendar events, weather, recent moments, and active projects in flowing prose, then invites the user to continue the conversation throughout the day.
**Schedule**Configurable slots: morning (default 4am compile), midday (8am check-in), evening (12pm check-in), night (4pm). Scheduler catches up missed slots on startup.
**Schedule**Daily prep generates at a configurable time (default 5:00am). The "day rollover hour" controls when the journal switches to a new day (default 4am — late-night entries 13am still count as the previous day). Scheduler catches up missed runs on startup.
**Configuration** — Settings → Briefing: enable toggle, location geocoding, office days, time slot toggles, RSS feed management, push notification toggle.
**Right rail** — The journal view shows current weather conditions and upcoming events for the next two weeks alongside the conversation. Both surfaces draw from the same data the prep references.
**RSS feeds** — Add feed URLs with optional name and category. Feeds are fetched and cached; the briefing digest includes recent items. Category badges shown in the UI. Feeds can be manually refreshed.
**Configuration** — Settings → Profile:
- *Locations* section: home and work place-name inputs (geocoded on blur via Nominatim) and a temperature unit toggle (C/F)
- *Journal* section: prep auto-generate toggle, prep generation time, day rollover hour
- *About You* / *Interests* / *Work Schedule* / *Response Preferences* feed personalization into the prep's system prompt
**Weather** — Location-based forecast via Open-Meteo. Multiple locations supported (home, work, or any city name). Geocoding via Nominatim.
**Weather** — Location-based forecast via Open-Meteo. Up to two named locations (home, work). Cached rows auto-refresh in the background when the journal page loads.
**Profile note** — The assistant maintains a profile note for each user that it updates based on briefing conversations, improving personalisation over time.
**What the assistant has learned** — The assistant maintains a per-user observation log + consolidated summary, generated from journal and chat conversations. The summary is included in the journal's system prompt so the daily prep can reference what it knows about you over time.
## Web Research
@@ -115,10 +118,10 @@ Settings are tabbed:
|-----|----------|
| General | Assistant name, default model, model management (pull/delete) |
| Account | Email change, password change, session invalidation |
| Notifications | Push notification subscription, briefing push toggle |
| Notifications | Push notification subscription, journal prep push toggle |
| Profile | About you, response preferences, interests, work schedule, locations + temperature unit, journal prep schedule, learned observations |
| Integrations | CalDAV configuration, SearXNG status |
| Data | Personal export, backup/restore (admin) |
| Briefing | Enable, location, office days, slots, RSS feeds, weather |
| API Keys | Create/revoke API keys, Fable MCP download and install |
| Config (admin) | Base URL, SMTP, OIDC settings |
| Users (admin) | User list, invite links, registration toggle |
+807
View File
@@ -24,6 +24,7 @@
"@tiptap/vue-3": "^3.0.0",
"d3": "^7",
"dompurify": "^3.1.0",
"lucide-vue-next": "^0.469.0",
"marked": "^17.0.0",
"pinia": "^3.0.0",
"vue": "3.5.30",
@@ -91,6 +92,278 @@
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"cpu": [
@@ -106,6 +379,159 @@
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"license": "MIT",
@@ -284,6 +710,294 @@
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.59.0",
"cpu": [
@@ -296,6 +1010,90 @@
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@tiptap/core": {
"version": "3.20.1",
"license": "MIT",
@@ -1908,6 +2706,15 @@
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lucide-vue-next": {
"version": "0.469.0",
"resolved": "https://registry.npmjs.org/lucide-vue-next/-/lucide-vue-next-0.469.0.tgz",
"integrity": "sha512-EjOap+vY3xEzCMrnaccDHO4BH3k3Lr+sOyvzRQCaayYxkxKla0w6Jr4h3cHAzA4vMSp63Dcy7vDiGeCPcCY+Gg==",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"license": "MIT",
+1
View File
@@ -25,6 +25,7 @@
"@tiptap/vue-3": "^3.0.0",
"d3": "^7",
"dompurify": "^3.1.0",
"lucide-vue-next": "^0.469.0",
"marked": "^17.0.0",
"pinia": "^3.0.0",
"vue": "3.5.30",
+22 -18
View File
@@ -50,34 +50,42 @@
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Save: Moss action-primary per the Hybrid rule. Saving is "operating
the software" — not a brand moment. Accent gradient is reserved for
Send / empty-state CTAs. */
.btn-save {
padding: 0.45rem 1.1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 600;
font-weight: 500;
font-size: 0.875rem;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
transition: box-shadow 0.15s, opacity 0.15s;
transition: background 0.15s, opacity 0.15s;
}
.btn-save:hover:not(:disabled) {
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
opacity: 0.95;
background: var(--color-action-primary-hover);
}
.btn-save:disabled {
opacity: 0.55;
cursor: default;
}
/* Delete: Oxblood action-destructive per Hybrid rule. Should be paired
with a Trash icon at the call site to reinforce intent. */
.btn-delete {
padding: 0.45rem 1rem;
background: var(--color-danger);
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;
@@ -99,7 +107,7 @@
border-bottom: 1.5px solid var(--color-border);
border-radius: 0;
font-size: 1.5rem;
font-weight: 700;
font-weight: 500;
font-family: "Fraunces", Georgia, serif;
background: transparent;
color: var(--color-text);
@@ -112,7 +120,6 @@
}
.title-input::placeholder {
color: var(--color-text-muted);
font-style: italic;
font-weight: 400;
}
.editor-tabs {
@@ -225,7 +232,7 @@
.assist-panel-title {
flex: 1;
font-size: 0.8rem;
font-weight: 700;
font-weight: 500;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -269,7 +276,7 @@
/* Section list */
.assist-sections-label {
font-size: 0.72rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
@@ -362,7 +369,6 @@
.assist-streaming-label {
font-size: 0.8rem;
color: var(--color-text-secondary);
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -391,7 +397,6 @@
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
text-align: center;
}
@@ -411,7 +416,7 @@
align-items: center;
justify-content: space-between;
font-size: 0.8rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text-secondary);
}
.btn-toggle-view {
@@ -454,7 +459,7 @@
width: 1rem;
text-align: center;
user-select: none;
font-weight: 700;
font-weight: 500;
}
.diff-text {
flex: 1;
@@ -464,7 +469,6 @@
.diff-empty {
padding: 0.5rem;
color: var(--color-text-muted);
font-style: italic;
font-size: 0.82rem;
}
.assist-actions {
@@ -562,7 +566,7 @@
border: none;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text-secondary);
cursor: pointer;
text-align: left;
@@ -581,7 +585,7 @@
}
.sb-label {
font-size: 0.78rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
+4 -1
View File
@@ -1,5 +1,8 @@
.prose {
line-height: 1.6;
/* Long-form reading line-height per the design system —
reading surfaces (notes, tasks, journal, assistant bubbles) want 1.7
so prose breathes like a book, not a UI snippet. */
line-height: 1.7;
}
.prose h1 {
+149 -114
View File
@@ -1,131 +1,147 @@
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&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');
:root {
--color-bg: #f5f5fb;
--color-bg-secondary: #ededf5;
--color-bg-card: #ffffff;
--color-surface: #f0f0f8;
--color-text: #1a1a1a;
--color-text-secondary: #666666;
--color-text-muted: #999999;
--color-border: #dddde8;
--color-input-border: #c8c8d8;
--color-primary: #7c3aed;
--color-danger: #d93025;
--color-tag-bg: #ede5ff;
--color-tag-text: #6d28d9;
/* Light mode — warm parchment palette */
--color-bg: #F5F1E8;
--color-bg-secondary: #FBF8F0;
--color-bg-card: #FBF8F0;
--color-surface: #EFEAE0;
--color-text: #14171A;
--color-text-secondary: #5A5852;
--color-text-muted: #9A9890;
--color-border: #D9D6CE;
--color-input-border: #D9D6CE;
--color-primary: #5B4A8A;
--color-danger: #C04A1F;
--color-tag-bg: rgba(91, 74, 138, 0.12);
--color-tag-text: #5B4A8A;
--color-shadow: rgba(0, 0, 0, 0.08);
--color-toast-success: #34a853;
--color-toast-error: #d93025;
--color-status-todo: #5f6368;
--color-status-todo-bg: #e8eaed;
--color-status-in-progress: #7c3aed;
--color-status-in-progress-bg: #ede5ff;
--color-status-done: #34a853;
--color-status-done-bg: #e6f4ea;
--color-priority-low: #5f9ea0;
--color-priority-low-bg: #e0f2f1;
--color-priority-medium: #f9a825;
--color-priority-medium-bg: #fff8e1;
--color-priority-high: #d93025;
--color-priority-high-bg: #fce8e6;
--color-wikilink: #7b1fa2;
--color-wikilink-bg: #f3e5f5;
--color-overdue: #d93025;
--color-code-bg: #f0f0f8;
--color-code-inline-bg: #eaeaf4;
--color-table-stripe: #f4f4fb;
--color-success: #22c55e;
--color-warning: #eab308;
--color-input-bar-bg: #eaeaf3;
--color-input-bar-text: #1a1a1a;
--color-input-bar-placeholder: 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.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: rgba(0, 0, 0, 0.04);
--color-bubble-user-border: rgba(0, 0, 0, 0.10);
--color-bubble-user-text: #3a3a4a;
--color-bubble-asst-shadow: 0 2px 16px rgba(124, 58, 237, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
--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 */
--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;
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-pill: 9999px;
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
--focus-ring: 0 0 0 2px rgba(91, 74, 138, 0.5);
/* Layout */
--page-max-width: 1200px;
--page-padding-x: 1rem;
--sidebar-width: 260px;
--chat-reading-width: min(1200px, 100%);
--chat-context-sidebar-width: 220px;
--color-accent-warm: #b8860b;
--color-accent-warm-light: #d4a017;
--color-primary-solid: #7c3aed;
--color-primary-deep: #5b21b6;
/* Reusable brand patterns */
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
--glow-cta: 0 2px 10px rgba(124, 58, 237, 0.35);
--glow-cta-hover: 0 4px 20px rgba(124, 58, 237, 0.55);
--glow-soft: 0 0 16px rgba(124, 58, 237, 0.35);
--color-primary-faint: rgba(124, 58, 237, 0.08);
--color-primary-tint: rgba(124, 58, 237, 0.12);
--color-primary-wash: rgba(124, 58, 237, 0.20);
}
[data-theme="dark"] {
--color-bg: #0f0f14;
--color-bg-secondary: #16161f;
--color-bg-card: #1a1a24;
--color-surface: #16161f;
--color-text: #e4e4f0;
--color-text-secondary: #8888a8;
--color-text-muted: #52526a;
--color-border: rgba(124, 58, 237, 0.22);
--color-input-border: rgba(124, 58, 237, 0.35);
--color-primary: #a78bfa;
--color-danger: #f44336;
--color-tag-bg: #2a2a45;
--color-tag-text: #c4b5fd;
/* Dark mode — Obsidian / Iron / Pewter */
--color-bg: #14171A;
--color-bg-secondary: #1E2228;
--color-bg-card: #1E2228;
--color-surface: #2C313A;
--color-text: #E8E4D8;
--color-text-secondary: #C2BFB4;
--color-text-muted: #9C9A92;
--color-border: #3F4651;
--color-input-border: #3F4651;
--color-primary: #5B4A8A;
--color-danger: #C04A1F;
--color-tag-bg: rgba(91, 74, 138, 0.15);
--color-tag-text: #5B4A8A;
--color-shadow: rgba(0, 0, 0, 0.4);
--color-toast-success: #4caf50;
--color-toast-error: #f44336;
--color-status-todo: #9aa0a6;
--color-status-todo-bg: #2a2a35;
--color-status-in-progress: #a78bfa;
--color-status-in-progress-bg: #2a2a45;
--color-status-done: #4caf50;
--color-status-done-bg: #1b3a20;
--color-priority-low: #80cbc4;
--color-priority-low-bg: #1a3a38;
--color-priority-medium: #fdd835;
--color-priority-medium-bg: #3a3520;
--color-priority-high: #f44336;
--color-priority-high-bg: #3a1a1a;
--color-wikilink: #c4b5fd;
--color-wikilink-bg: #2a1a45;
--color-overdue: #f44336;
--color-code-bg: #12121a;
--color-code-inline-bg: #1a1a2a;
--color-table-stripe: #14141e;
--color-success: #4ade80;
--color-warning: #facc15;
--color-input-bar-bg: #1a1a24;
--color-input-bar-text: #e4e4f0;
--color-input-bar-placeholder: rgba(228, 228, 240, 0.35);
--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: rgba(255, 255, 255, 0.04);
--color-bubble-user-border: rgba(255, 255, 255, 0.10);
--color-bubble-user-text: #b0b0c8;
--color-bubble-asst-shadow: 0 4px 28px rgba(124, 58, 237, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
--color-accent-warm: #d4a017;
--color-accent-warm-light: #e8c45a;
--color-primary-solid: #7c3aed;
--color-primary-deep: #5b21b6;
--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(124, 58, 237, 0.45);
--glow-cta-hover: 0 4px 24px rgba(124, 58, 237, 0.65);
--glow-soft: 0 0 18px rgba(124, 58, 237, 0.4);
--color-primary-faint: rgba(124, 58, 237, 0.10);
--color-primary-tint: rgba(124, 58, 237, 0.14);
--color-primary-wash: rgba(124, 58, 237, 0.22);
--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;
}
*,
@@ -138,15 +154,34 @@ body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, sans-serif;
font-feature-settings: "cv11";
line-height: 1.5;
transition: background-color 0.2s, color 0.2s;
}
h1, h2, h3 {
h1, h2 {
font-family: 'Fraunces', Georgia, serif;
font-optical-sizing: auto;
font-weight: 500;
line-height: 1.3;
}
h3 {
font-family: 'Inter', system-ui, sans-serif;
font-weight: 500;
line-height: 1.3;
}
code, pre, kbd, samp {
font-family: 'JetBrains Mono', ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-feature-settings: "liga", "calt";
}
::selection {
background: rgba(91, 74, 138, 0.3);
color: var(--color-text);
}
input:focus-visible,
@@ -184,7 +219,7 @@ button:not(:disabled):active,
}
}
/* Thin indigo-tinted scrollbars */
/* Neutral hairline scrollbars — chrome is structural, not branded */
::-webkit-scrollbar {
width: 4px;
height: 4px;
@@ -193,11 +228,11 @@ button:not(:disabled):active,
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(124, 58, 237, 0.25);
background: var(--color-border);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(124, 58, 237, 0.45);
background: var(--color-text-muted);
}
/* Floating inline assist button (teleported to body, cannot be scoped) */
+19 -16
View File
@@ -7,6 +7,7 @@ import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat";
import AppLogo from "@/components/AppLogo.vue";
import NotificationBell from "@/components/NotificationBell.vue";
import { Sun, Moon, Settings } from "lucide-vue-next";
const { theme, toggleTheme } = useTheme();
const { toggleShortcuts } = useShortcuts();
@@ -94,15 +95,13 @@ router.afterEach(() => {
<button class="btn-icon" @click="toggleShortcuts" title="Keyboard shortcuts (?)">?</button>
<button class="btn-icon" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
{{ theme === "dark" ? "\u2600" : "\u263E" }}
<Sun v-if="theme === 'dark'" :size="16" />
<Moon v-else :size="16" />
</button>
<!-- Settings link -->
<router-link to="/settings" class="btn-icon" aria-label="Settings" title="Settings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<Settings :size="16" />
</router-link>
<div class="user-info">
@@ -137,7 +136,8 @@ router.afterEach(() => {
<span class="status-text">{{ statusShortLabel }}</span>
</span>
<button class="btn-icon" @click="toggleShortcuts">?</button>
<button class="btn-icon" @click="toggleTheme">{{ theme === "dark" ? "\u2600" : "\u263E" }}</button>
<button class="btn-icon" @click="toggleTheme"><Sun v-if="theme === 'dark'" :size="16" />
<Moon v-else :size="16" /></button>
</div>
<div class="mobile-user">
<span class="username">{{ authStore.user?.username }}</span>
@@ -151,7 +151,7 @@ router.afterEach(() => {
<style scoped>
.app-header {
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
border-bottom: 1px solid rgba(124, 58, 237, 0.18);
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
position: relative;
}
.nav {
@@ -172,9 +172,8 @@ router.afterEach(() => {
}
.brand-text {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-optical-sizing: auto;
font-weight: 600;
font-weight: 500;
font-size: 1rem;
letter-spacing: -0.01em;
color: #c4b0f0;
@@ -219,9 +218,9 @@ router.afterEach(() => {
}
.nav-link.router-link-active {
color: var(--color-primary-solid);
font-weight: 600;
background: rgba(124, 58, 237, 0.25);
box-shadow: 0 0 16px rgba(124, 58, 237, 0.3);
font-weight: 500;
background: rgba(91, 74, 138, 0.25);
box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
}
/* Status indicator */
@@ -243,10 +242,14 @@ router.afterEach(() => {
font-weight: 500;
color: var(--color-text-muted);
}
.status-green .status-dot { background: var(--color-success, #2ecc71); animation: status-pulse 2.5s ease-in-out infinite; }
.status-yellow .status-dot { background: var(--color-warning, #f59e0b); animation: pulse-dot 2s infinite; }
/* Status dots are indicator lights, not semantic-palette buttons —
they want to read as vital (Moss/Warning/Error are too muted for
a "ready" indicator). Hardcoded bright values; the rest of the
system still uses the semantic tokens. */
.status-green .status-dot { background: #4ade80; animation: status-pulse 2.5s ease-in-out infinite; }
.status-yellow .status-dot { background: #facc15; animation: pulse-dot 2s infinite; }
.status-orange .status-dot { background: #f97316; }
.status-red .status-dot { background: var(--color-danger, #e74c3c); }
.status-red .status-dot { background: #ef4444; }
.status-gray .status-dot { background: var(--color-text-muted); animation: pulse-dot 2s infinite; }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
@@ -294,7 +297,7 @@ router.afterEach(() => {
}
.admin-badge {
font-size: 0.65rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-primary);
+20 -19
View File
@@ -10,6 +10,15 @@ import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import { useToastStore } from '@/stores/toast'
import type { Note } from '@/types/note'
import {
Paperclip,
Volume2,
VolumeX,
Square,
Mic,
Loader2,
ArrowUp,
} from 'lucide-vue-next'
const props = withDefaults(defineProps<{
/** Textarea placeholder */
@@ -277,9 +286,7 @@ defineExpose({ focus, prefill })
:disabled="!store.chatReady"
title="Attach a note"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
</svg>
<Paperclip :size="16" />
</button>
<div v-if="showNotePicker" class="note-picker-dropdown">
<input
@@ -322,12 +329,8 @@ defineExpose({ focus, prefill })
:title="listenMode ? 'Listen mode on' : 'Listen / volume'"
aria-label="Listen and volume settings"
>
<svg v-if="!tts.speaking.value" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
<Volume2 v-if="!tts.speaking.value" :size="16" />
<VolumeX v-else :size="16" />
</button>
<div v-if="speakerPopoverOpen" class="speaker-popover">
<button
@@ -354,7 +357,7 @@ defineExpose({ focus, prefill })
class="speaker-row speaker-row--stop"
@click="tts.stop()"
>
<svg width="12" height="12" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
<Square :size="16" fill="currentColor" />
<span>Stop playback</span>
</button>
</div>
@@ -371,12 +374,8 @@ defineExpose({ focus, prefill })
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
>
<svg v-if="!transcribingVoice" width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
<svg v-else width="17" height="17" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="8" opacity="0.35"/><circle cx="12" cy="12" r="4"/>
</svg>
<Mic v-if="!transcribingVoice" :size="16" />
<Loader2 v-else :size="16" class="mic-loader" />
</button>
</div>
@@ -387,14 +386,14 @@ defineExpose({ focus, prefill })
@click="emit('abort')"
title="Stop generation"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><rect width="14" height="14" rx="2"/></svg>
<Square :size="16" fill="currentColor" />
</button>
<button
v-else
class="btn-send"
@click="onSubmit"
:disabled="!messageInput.trim() || !store.chatReady"
>&uarr;</button>
><ArrowUp :size="16" /></button>
</div>
</div>
</template>
@@ -484,6 +483,8 @@ defineExpose({ focus, prefill })
z-index: 1;
}
.btn-mic.mic-transcribing { opacity: 0.5; }
.mic-loader { animation: mic-spin 1s linear infinite; }
@keyframes mic-spin { to { transform: rotate(360deg); } }
/* Mic wrapper + live amplitude halo. The glow is a filled red disc
absolutely positioned behind the button, scaled/faded by the
@@ -573,7 +574,7 @@ defineExpose({ focus, prefill })
flex-shrink: 0;
transition: box-shadow 0.15s;
}
.btn-send:hover { box-shadow: 0 0 16px rgba(124, 58, 237, 0.35); }
.btn-send:hover { box-shadow: 0 0 16px rgba(91, 74, 138, 0.35); }
.btn-send:disabled { opacity: 0.35; cursor: default; box-shadow: none; }
.btn-abort-inline {
+9 -4
View File
@@ -155,7 +155,7 @@ const timingParts = computed((): string[] => {
}
.role-label {
font-size: 0.75rem;
font-weight: 600;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
@@ -167,7 +167,6 @@ const timingParts = computed((): string[] => {
color: var(--color-primary);
font-family: 'Fraunces', Georgia, serif;
font-optical-sizing: auto;
font-style: italic;
font-size: 0.8rem;
text-transform: none;
letter-spacing: 0;
@@ -175,14 +174,14 @@ const timingParts = computed((): string[] => {
.thinking-block {
margin-bottom: 0.5rem;
border-left: 2px solid rgba(129, 140, 248, 0.35);
border-left: 2px solid rgba(91, 74, 138, 0.35);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
overflow: hidden;
}
.thinking-summary {
padding: 0.25rem 0.5rem;
font-size: 0.72rem;
font-weight: 600;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
@@ -215,6 +214,12 @@ details[open] .thinking-summary::before {
overflow-y: auto;
background: var(--color-bg-secondary);
}
/* Long-form line-height (1.7) on assistant bubbles per the design system —
chat is a reading surface, not a snippet stream. User bubbles stay tighter. */
.role-assistant .message-content {
line-height: 1.7;
}
.message-content {
font-size: 0.95rem;
line-height: 1.55;
+19 -22
View File
@@ -6,6 +6,7 @@ import ChatStreamingBubble from '@/components/ChatStreamingBubble.vue'
import ChatInputBar from '@/components/ChatInputBar.vue'
import ToolCallCard from '@/components/ToolCallCard.vue'
import type { Message, ToolCallRecord } from '@/types/chat'
import { Mic, ChevronDown, X } from 'lucide-vue-next'
const props = withDefaults(defineProps<{
variant: 'full' | 'widget'
@@ -372,7 +373,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
</router-link>
</div>
<button class="empty-voice-btn" @click="startVoiceMode" title="Start in voice mode">
<span class="voice-icon">🎤</span>
<Mic class="voice-icon" :size="16" />
<span>Start in voice mode</span>
</button>
</div>
@@ -391,7 +392,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
:class="{ collapsed: collapsedSections.has('auto') }"
@click="toggleSection('auto')"
>
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
<ChevronDown class="chev" :size="16" />
<span>Auto-included</span>
<span class="section-count">{{ autoInjectedNotes.length }}</span>
</button>
@@ -404,7 +405,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
class="context-note-score"
:class="{ 'score-high': note.score >= 0.75, 'score-medium': note.score >= 0.60 && note.score < 0.75, 'score-low': note.score < 0.60 }"
>{{ Math.round(note.score * 100) }}%</span>
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Remove from context">&times;</button>
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Remove from context"><X :size="16" /></button>
</div>
</div>
</template>
@@ -414,7 +415,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
:class="{ collapsed: collapsedSections.has('suggested'), 'context-sidebar-header-gap': autoInjectedNotes.length }"
@click="toggleSection('suggested')"
>
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
<ChevronDown class="chev" :size="16" />
<span>Suggested</span>
<span class="section-count">{{ suggestedNotes.length }}</span>
</button>
@@ -436,14 +437,14 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
:class="{ collapsed: collapsedSections.has('included'), 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }"
@click="toggleSection('included')"
>
<svg class="chev" width="10" height="10" viewBox="0 0 10 10" fill="currentColor"><path d="M2 3l3 4 3-4z"/></svg>
<ChevronDown class="chev" :size="16" />
<span>In Context</span>
<span class="section-count">{{ includedNotes.length }}</span>
</button>
<div v-show="!collapsedSections.has('included')">
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
<router-link :to="`/notes/${note.id}`" class="context-note-name">{{ note.title }}</router-link>
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context">&times;</button>
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context"><X :size="16" /></button>
</div>
</div>
</template>
@@ -579,7 +580,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
padding: 0.15rem 0.1rem;
font-family: inherit;
font-size: 0.65rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
@@ -597,7 +598,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
.context-sidebar-header .section-count {
margin-left: auto;
color: var(--color-text-muted);
font-weight: 600;
font-weight: 500;
background: var(--color-bg);
padding: 0.05rem 0.3rem;
border-radius: 8px;
@@ -621,7 +622,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
}
.auto-pill {
font-size: 0.55rem;
font-weight: 700;
font-weight: 500;
letter-spacing: 0.05em;
padding: 0.05rem 0.3rem;
border-radius: 4px;
@@ -685,7 +686,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
font-weight: 700;
font-weight: 500;
margin-bottom: 0.2rem;
}
.queued-clear-row {
@@ -703,13 +704,11 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
}
.empty-msg {
color: var(--color-text-muted);
color: var(--color-text-secondary);
font-size: 0.9rem;
text-align: center;
padding: 2rem 1rem;
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
color: var(--color-accent-warm, #d4a017);
}
.chat-empty-state {
@@ -724,17 +723,16 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
.empty-greeting {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-weight: 400;
font-size: 1.75rem;
color: var(--color-accent-warm, #d4a017);
color: var(--color-text-secondary);
text-align: center;
margin: 0;
}
.empty-section-label {
font-size: 0.7rem;
font-weight: 600;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
@@ -768,7 +766,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
.empty-recent-item:hover {
border-color: var(--color-primary);
background: rgba(99, 102, 241, 0.05);
background: rgba(91, 74, 138, 0.05);
transform: translateX(2px);
}
@@ -805,9 +803,9 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
.empty-voice-btn:hover {
border-color: var(--color-primary);
background: linear-gradient(135deg, #6366f1, #4f46e5);
background: linear-gradient(135deg, #5B4A8A, #3F3560);
color: #fff;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35);
box-shadow: 0 4px 16px rgba(91, 74, 138, 0.35);
}
.empty-voice-btn .voice-icon {
@@ -828,7 +826,6 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
.widget-query {
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
}
.widget-text {
font-size: 0.9rem;
@@ -871,7 +868,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
.btn-open-chat.prominent {
font-size: 0.9rem;
color: var(--color-primary);
font-weight: 600;
font-weight: 500;
}
.btn-clear-response {
background: none;
@@ -918,7 +915,7 @@ defineExpose({ focus, prefill, send, contextCount, sidebarOpen, toggleContextSid
:deep(.thinking-summary) {
padding: 0.25rem 0.5rem;
font-size: 0.72rem;
font-weight: 600;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
-2
View File
@@ -124,7 +124,6 @@ function markerFor(type: DiffLine['type']): string {
padding: 0.75rem;
font-size: 0.85rem;
color: var(--color-text-muted);
font-style: italic;
}
.diff-line {
@@ -153,7 +152,6 @@ function markerFor(type: DiffLine['type']): string {
.diff-collapse {
color: var(--color-text-muted);
opacity: 0.6;
font-style: italic;
border-top: 1px dashed var(--color-border);
border-bottom: 1px dashed var(--color-border);
padding-top: 0.2rem;
+28 -20
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { X } from "lucide-vue-next";
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
import ProjectSelector from "@/components/ProjectSelector.vue";
@@ -259,7 +260,7 @@ async function doDelete() {
<div class="slide-over-panel" role="dialog" aria-modal="true">
<div class="so-header">
<h2 class="so-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
<button class="so-close" @click="emit('close')" aria-label="Close"></button>
<button class="so-close" @click="emit('close')" aria-label="Close"><X :size="16" /></button>
</div>
<form class="so-form" @submit.prevent="save">
@@ -315,8 +316,8 @@ async function doDelete() {
<label class="so-label so-label-inline">Color</label>
<div class="color-row">
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
<input v-model="color" class="so-input color-hex" placeholder="#7c3aed" />
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"></button>
<input v-model="color" class="so-input color-hex" placeholder="#5B4A8A" />
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"><X :size="16" /></button>
</div>
</div>
@@ -389,7 +390,7 @@ async function doDelete() {
.so-title {
font-size: 1.05rem;
font-weight: 600;
font-weight: 500;
margin: 0;
color: var(--color-text, #e8e9f0);
}
@@ -419,7 +420,7 @@ async function doDelete() {
.so-label {
font-size: 0.78rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.04em;
@@ -450,8 +451,7 @@ async function doDelete() {
.so-past-hint {
margin: 4px 0 0;
font-size: 0.75rem;
color: var(--color-accent-warm, #d4a017);
font-style: italic;
color: var(--color-text-secondary);
}
.toggle-btn {
@@ -491,53 +491,61 @@ async function doDelete() {
margin-top: auto;
}
/* Save (in slide-over): Moss action-primary */
.btn-primary {
background: var(--gradient-cta);
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1.2rem;
font-size: 0.9rem;
font-weight: 600;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
transition: background 0.15s;
}
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary:hover:not(:disabled) { opacity: 0.88; }
.btn-primary:hover:not(:disabled) { background: var(--color-action-primary-hover); }
/* Cancel: Bronze action-secondary */
.btn-secondary {
background: var(--color-input-bg, #111113);
border: 1px solid var(--color-border, #2a2b30);
color: var(--color-text-muted, #888);
background: var(--color-action-secondary);
border: none;
color: #fff;
border-radius: 8px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-secondary:hover { background: var(--color-hover, rgba(255,255,255,0.06)); }
.btn-secondary:hover { background: var(--color-action-secondary-hover); }
/* Delete (entry-point): Oxblood action-destructive ghost */
.btn-danger-ghost {
margin-left: auto;
background: none;
border: 1px solid #ef4444;
color: #ef4444;
border: 1px solid var(--color-action-destructive);
color: var(--color-action-destructive);
border-radius: 8px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn-danger-ghost:hover { background: rgba(239, 68, 68, 0.1); }
.btn-danger-ghost:hover { background: var(--color-action-destructive); color: #fff; }
/* Confirm-delete: Oxblood filled */
.btn-danger {
background: #ef4444;
background: var(--color-action-destructive);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
font-weight: 600;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
.delete-confirm-label {
-1
View File
@@ -244,7 +244,6 @@ onMounted(loadVersions);
padding: 1rem;
font-size: 0.85rem;
color: var(--color-text-muted);
font-style: italic;
}
.history-footer {
@@ -151,7 +151,6 @@ const markers: Record<DiffLine["type"], string> = {
padding: 0.75rem;
font-size: 0.85rem;
color: var(--color-text-muted);
font-style: italic;
}
/* ── Review ── */
+33 -17
View File
@@ -1,24 +1,40 @@
<script setup lang="ts">
import type { Editor } from "@tiptap/vue-3";
import {
Bold,
Italic,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
ListChecks,
Code,
SquareCode,
Quote,
Link as LinkIcon,
Brackets,
} from "lucide-vue-next";
import type { Component } from "vue";
const props = defineProps<{ editor: Editor | null }>();
// SVG icons — Material Design paths (24x24) or custom SVG
const ICONS: Record<string, string> = {
bold: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/></svg>`,
italic: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"/></svg>`,
strike: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z"/></svg>`,
h1: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">1</text></svg>`,
h2: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">2</text></svg>`,
h3: `<svg viewBox="0 0 24 24" width="15" height="15"><text x="1" y="17" font-family="sans-serif" font-weight="800" font-size="16" fill="currentColor">H</text><text x="14" y="17" font-family="sans-serif" font-weight="700" font-size="11" fill="currentColor">3</text></svg>`,
ul: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/></svg>`,
ol: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2zm1-9h1V4H2v1h1zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2zm5-3h13V6H7v2zm0 4h13v-2H7v2zm0 4h13v-2H7v2z"/></svg>`,
task: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>`,
code: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></svg>`,
codeblock: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M20 3H4v10c0 2.21 1.79 4 4 4h6c2.21 0 4-1.79 4-4v-3h2c1.11 0 2-.89 2-2V5c0-1.11-.89-2-2-2zm0 5h-2V5h2v3zM4 19h16v2H4z"/></svg>`,
quote: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg>`,
link: `<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>`,
wikilink: `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3"/><path d="M16 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3"/></svg>`,
const ICONS: Record<string, Component> = {
bold: Bold,
italic: Italic,
strike: Strikethrough,
h1: Heading1,
h2: Heading2,
h3: Heading3,
ul: List,
ol: ListOrdered,
task: ListChecks,
code: Code,
codeblock: SquareCode,
quote: Quote,
link: LinkIcon,
wikilink: Brackets,
};
const groups = [
@@ -81,7 +97,7 @@ const groups = [
tabindex="-1"
@mousedown.prevent="btn.command()"
>
<span class="btn-icon" v-html="ICONS[btn.id]" />
<component :is="ICONS[btn.id]" class="btn-icon" :size="16" />
</button>
</div>
<span v-if="gi < groups.length - 1" class="toolbar-sep" aria-hidden="true" />
+3 -3
View File
@@ -64,11 +64,11 @@ function goEdit() {
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(124, 58, 237, 0.06);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
transition: box-shadow 0.2s, transform 0.18s ease;
}
.note-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(124, 58, 237, 0.14);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
transform: translateY(-2px);
}
@@ -89,7 +89,7 @@ function goEdit() {
}
.note-card.compact:hover {
box-shadow: none;
background: rgba(124, 58, 237, 0.04);
background: rgba(91, 74, 138, 0.04);
transform: none;
}
.note-title-compact {
+2 -4
View File
@@ -2,6 +2,7 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { useNotificationsStore } from '@/stores/notifications'
import NotificationsPanel from './NotificationsPanel.vue'
import { Bell } from 'lucide-vue-next'
const store = useNotificationsStore()
const open = ref(false)
@@ -45,10 +46,7 @@ onUnmounted(() => {
aria-label="Notifications"
:title="`${store.count} unread notification${store.count !== 1 ? 's' : ''}`"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
<Bell :size="16" aria-hidden="true" />
<span v-if="store.count > 0" class="bell-badge" aria-live="polite">{{ store.count > 99 ? '99+' : store.count }}</span>
</button>
<NotificationsPanel v-if="open" @close="close" />
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { X } from "lucide-vue-next";
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useNotificationsStore } from '@/stores/notifications'
@@ -56,7 +57,7 @@ onMounted(() => store.fetchAll())
</p>
<span class="notif-time">{{ relativeTime(n.created_at) }}</span>
</div>
<button class="btn-notif-close" @click.stop="store.markRead(n.id)" aria-label="Dismiss"></button>
<button class="btn-notif-close" @click.stop="store.markRead(n.id)" aria-label="Dismiss"><X :size="16" /></button>
</li>
</ul>
<div v-else class="notif-empty">No unread notifications</div>
@@ -151,6 +152,5 @@ onMounted(() => store.fetchAll())
text-align: center;
color: var(--color-muted);
font-size: 0.88rem;
font-style: italic;
}
</style>
+3 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { X } from "lucide-vue-next";
import { ref, onMounted } from 'vue'
import {
searchUsers,
@@ -116,7 +117,7 @@ onMounted(async () => {
<div class="share-dialog" role="dialog" :aria-label="`Share ${resourceTitle}`">
<header class="share-header">
<h2 class="share-title">Share "{{ resourceTitle }}"</h2>
<button class="btn-close" @click="emit('close')" aria-label="Close"></button>
<button class="btn-close" @click="emit('close')" aria-label="Close"><X :size="16" /></button>
</header>
<!-- Add share form -->
@@ -183,7 +184,7 @@ onMounted(async () => {
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<button class="btn-remove-share" @click="removeShare(share)" aria-label="Remove"></button>
<button class="btn-remove-share" @click="removeShare(share)" aria-label="Remove"><X :size="16" /></button>
</li>
<li v-if="!shares.length" class="shares-empty">Not shared with anyone yet</li>
</ul>
@@ -409,7 +410,6 @@ onMounted(async () => {
.shares-empty {
color: var(--color-text-muted);
font-size: 0.85rem;
font-style: italic;
text-align: center;
padding: 0.75rem;
}
+2 -2
View File
@@ -112,11 +112,11 @@ function isOverdue(): boolean {
text-decoration: none;
color: inherit;
background: var(--color-bg-card);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(124, 58, 237, 0.06);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(91, 74, 138, 0.06);
transition: box-shadow 0.2s, transform 0.18s ease;
}
.task-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(124, 58, 237, 0.14);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10), 0 0 0 1px rgba(91, 74, 138, 0.14);
transform: translateY(-2px);
}
+11 -9
View File
@@ -532,10 +532,13 @@ function closeEventSlideOver(changed = false) {
</template>
<style scoped>
/* Inline ToolCallCard renders inside an assistant bubble — the bubble
already contains it, so no outer border per the structural-not-decorative
rule. Background tint differentiates it; error state gets a left-edge
accent the way the assistant bubble itself uses one. */
.tool-call-card {
display: inline-block;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 10px;
font-size: 0.8rem;
margin-top: 0.4rem;
@@ -543,7 +546,7 @@ function closeEventSlideOver(changed = false) {
vertical-align: top;
}
.tool-call-card.error {
border-color: var(--color-danger, #e74c3c);
border-left: 3px solid var(--color-danger);
}
.tool-call-card.declined {
opacity: 0.55;
@@ -566,7 +569,7 @@ function closeEventSlideOver(changed = false) {
}
.tool-label {
font-weight: 600;
font-weight: 500;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
@@ -636,14 +639,14 @@ function closeEventSlideOver(changed = false) {
}
.web-search-results .tool-search-item::after { content: none; }
.tool-event-title { font-weight: 600; color: var(--color-text); }
.tool-event-title { font-weight: 500; color: var(--color-text); }
.tool-event-time { color: var(--color-text-muted); font-size: 0.75rem; }
.tool-event-list { display: flex; flex-direction: column; gap: 0.2rem; }
.tool-event-item { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; }
.tool-event-item-title { color: var(--color-text); font-size: 0.8rem; }
.tool-event-item-time { color: var(--color-text-muted); font-size: 0.75rem; white-space: nowrap; }
.tool-event-more { color: var(--color-text-muted); font-size: 0.75rem; font-style: italic; }
.tool-event-more { color: var(--color-text-muted); font-size: 0.75rem; }
.tool-deleted { text-decoration: line-through; opacity: 0.7; }
.tool-completed { text-decoration: line-through; color: var(--color-success, #2ecc71); }
@@ -652,7 +655,7 @@ function closeEventSlideOver(changed = false) {
.tool-note-tag { font-size: 0.72rem; color: var(--color-text-muted); }
.tool-task-priority {
font-size: 0.7rem; font-weight: 600; text-transform: uppercase;
font-size: 0.7rem; font-weight: 500; text-transform: uppercase;
letter-spacing: 0.04em; padding: 0.1rem 0.3rem; border-radius: 3px;
}
.priority-high { color: var(--color-danger, #e74c3c); }
@@ -697,12 +700,11 @@ function closeEventSlideOver(changed = false) {
.confirm-created {
color: var(--color-success, #2ecc71);
font-size: 0.78rem;
font-weight: 600;
font-weight: 500;
}
.confirm-denied {
color: var(--color-text-muted);
font-size: 0.78rem;
font-style: italic;
}
.btn-confirm-duplicate {
padding: 0.15rem 0.5rem;
@@ -792,7 +794,7 @@ function closeEventSlideOver(changed = false) {
}
.tool-event-card-title {
font-size: 0.8rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
@@ -202,7 +202,6 @@ function restore() {
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
}
.vh-item {
-1
View File
@@ -273,6 +273,5 @@ function hasPrecip(day: ForecastDay): boolean {
.weather-unavailable {
color: var(--color-text-muted);
font-style: italic;
}
</style>
+11 -10
View File
@@ -3,6 +3,7 @@ import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
import { useChatStore } from '@/stores/chat';
import ChatPanel from '@/components/ChatPanel.vue';
import ChatInputBar from '@/components/ChatInputBar.vue';
import { RotateCcw, ChevronDown, ChevronUp } from 'lucide-vue-next';
const props = defineProps<{ projectId: number }>();
@@ -179,7 +180,9 @@ defineExpose({ prefill });
:class="{ active: historyOpen }"
title="History"
@click="toggleHistory"
></button>
>
<ChevronDown :size="16" />
</button>
<div v-if="historyOpen" class="ws-chat-history-menu">
<div v-if="projectConversations.length === 0" class="ws-chat-history-empty">
No past conversations
@@ -195,11 +198,11 @@ defineExpose({ prefill });
</button>
</div>
</div>
<button class="btn-icon-sm" title="Restart conversation" @click="restart"></button>
<button class="btn-icon-sm" title="Restart conversation" @click="restart">
<RotateCcw :size="16" />
</button>
<button class="btn-icon-sm" title="Collapse" @click="collapse">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="6 9 12 15 18 9"/>
</svg>
<ChevronDown :size="16" />
</button>
</div>
</div>
@@ -217,9 +220,7 @@ defineExpose({ prefill });
<!-- Collapsed: standalone input bar with expand affordance -->
<div v-else class="ws-chat-collapsed">
<button class="ws-chat-expand-btn" title="Expand chat" @click="expand">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="18 15 12 9 6 15"/>
</svg>
<ChevronUp :size="16" />
</button>
<div class="ws-chat-input-row">
<ChatInputBar
@@ -264,7 +265,7 @@ defineExpose({ prefill });
}
.ws-chat-title {
font-size: 0.78rem;
font-weight: 600;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
@@ -339,7 +340,7 @@ defineExpose({ prefill });
}
.ws-chat-history-item.current {
color: var(--color-primary);
font-weight: 600;
font-weight: 500;
}
.ws-chat-history-title {
display: block;
+20 -17
View File
@@ -10,6 +10,7 @@ import TiptapEditor from "@/components/TiptapEditor.vue";
import TagInput from "@/components/TagInput.vue";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import WordCount from "@/components/WordCount.vue";
import { Trash2, X } from "lucide-vue-next";
const props = defineProps<{
projectId: number;
@@ -316,7 +317,7 @@ defineExpose({ reload: loadProjectNotes });
<button class="btn-confirm" :disabled="creatingNote || !newNoteTitle.trim()" @click="createNote">
{{ creatingNote ? '' : '+' }}
</button>
<button class="btn-cancel" aria-label="Cancel new note" @click="cancelNewNote"></button>
<button class="btn-cancel" aria-label="Cancel new note" @click="cancelNewNote"><X :size="16" /></button>
</template>
<template v-else>
<span class="rail-title">Notes</span>
@@ -332,7 +333,7 @@ defineExpose({ reload: loadProjectNotes });
type="search"
aria-label="Search notes"
/>
<button v-if="searchQuery" class="btn-search-clear" aria-label="Clear search" @click="searchQuery = ''"></button>
<button v-if="searchQuery" class="btn-search-clear" aria-label="Clear search" @click="searchQuery = ''"><X :size="16" /></button>
</div>
<div v-if="listLoading" class="rail-state">Loading</div>
@@ -360,10 +361,10 @@ defineExpose({ reload: loadProjectNotes });
<button class="btn-confirm-delete" :disabled="pendingDelete === note.id" @click="requestDelete(note.id, $event)">
{{ pendingDelete === note.id ? '' : 'Delete?' }}
</button>
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)"></button>
<button class="btn-cancel-delete" aria-label="Cancel delete" @click="cancelDelete($event)"><X :size="16" /></button>
</template>
<button v-else class="btn-delete" title="Delete note" @click="requestDelete(note.id, $event)">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
<Trash2 :size="16" />
</button>
</div>
</li>
@@ -416,7 +417,7 @@ defineExpose({ reload: loadProjectNotes });
:class="['btn-tag-suggestion', { applied: tagSuggestions.appliedTags.value.has(tag) }]"
@click="tagSuggestions.applyTagSuggestion(tag)"
>#{{ tag }}{{ tagSuggestions.appliedTags.value.has(tag) ? ' ✓' : '' }}</button>
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"></button>
<button class="btn-dismiss-suggestions" aria-label="Dismiss tag suggestions" @click="tagSuggestions.dismissTagSuggestions()"><X :size="16" /></button>
</div>
<div class="toolbar-row">
@@ -429,7 +430,7 @@ defineExpose({ reload: loadProjectNotes });
<button class="btn-chip-link" @click="applyLink(s)">[[{{ s.title }}]]</button>
</span>
<button class="btn-link-all" @click="applyAllLinks" title="Link all suggestions">All</button>
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"></button>
<button class="btn-dismiss-suggestions" aria-label="Dismiss link suggestions" @click="linkSuggestions = []"><X :size="16" /></button>
</div>
<div class="editor-area" @keydown.ctrl.s.prevent="saveNote" @keydown.ctrl.e.prevent="editorRef?.editor?.commands.focus()">
@@ -479,7 +480,7 @@ defineExpose({ reload: loadProjectNotes });
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.72rem;
font-weight: 600;
font-weight: 500;
flex: 1;
}
@@ -532,7 +533,6 @@ defineExpose({ reload: loadProjectNotes });
padding: 1rem 0.65rem;
font-size: 0.78rem;
color: var(--color-text-muted);
font-style: italic;
}
/* Note list */
@@ -630,18 +630,20 @@ defineExpose({ reload: loadProjectNotes });
align-items: center;
}
.note-row:hover .btn-delete { opacity: 1; }
.btn-delete:hover { color: var(--color-danger, #e74c3c); }
.btn-delete:hover { color: var(--color-action-destructive); }
.btn-confirm-delete {
background: none;
border: 1px solid var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
border: 1px solid var(--color-action-destructive);
color: var(--color-action-destructive);
font-size: 0.65rem;
font-weight: 600;
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 {
@@ -707,7 +709,6 @@ defineExpose({ reload: loadProjectNotes });
justify-content: center;
color: var(--color-text-muted);
font-size: 0.85rem;
font-style: italic;
}
/* Editor UI */
@@ -729,15 +730,18 @@ defineExpose({ reload: loadProjectNotes });
.unsaved { font-size: 0.72rem; color: var(--color-text-muted); }
.saving-txt { font-size: 0.72rem; color: var(--color-primary); }
/* Moss action-primary per Hybrid */
.btn-save {
background: var(--color-primary);
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 {
@@ -750,7 +754,7 @@ defineExpose({ reload: loadProjectNotes });
background: transparent;
border: none;
font-size: 1.4rem;
font-weight: 600;
font-weight: 500;
line-height: 1.25;
color: var(--color-text);
padding: 0;
@@ -760,7 +764,6 @@ defineExpose({ reload: loadProjectNotes });
.note-title-input:focus { outline: none; }
.note-title-input::placeholder {
color: var(--color-text-muted);
font-style: italic;
}
.tag-row {
@@ -843,7 +846,7 @@ defineExpose({ reload: loadProjectNotes });
font-size: 0.7rem;
color: var(--color-text-muted);
flex-shrink: 0;
font-weight: 600;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
+16 -13
View File
@@ -5,6 +5,7 @@ import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import TaskLogSection from "@/components/TaskLogSection.vue";
import { renderMarkdown } from "@/utils/markdown";
import { Trash2, X } from "lucide-vue-next";
const props = defineProps<{ projectId: number }>();
@@ -298,12 +299,12 @@ defineExpose({ reload: loadAll });
</span>
<template v-if="deleteConfirmPending">
<button class="btn-delete-confirm" :disabled="deletingTask" @click="deleteActiveTask">{{ deletingTask ? '...' : 'Delete?' }}</button>
<button class="btn-delete-cancel" aria-label="Cancel delete" @click="cancelDeleteTask"></button>
<button class="btn-delete-cancel" aria-label="Cancel delete" @click="cancelDeleteTask"><X :size="16" /></button>
</template>
<button v-else class="btn-delete-task" title="Delete task" @click="deleteActiveTask">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
<Trash2 :size="16" />
</button>
<button class="btn-close-detail" @click="closeTask" aria-label="Close detail"></button>
<button class="btn-close-detail" @click="closeTask" aria-label="Close detail"><X :size="16" /></button>
</div>
<h3 class="detail-title">{{ activeTask.title }}</h3>
@@ -373,7 +374,7 @@ defineExpose({ reload: loadAll });
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
font-weight: 600;
font-weight: 500;
}
.task-add {
@@ -432,7 +433,7 @@ defineExpose({ reload: loadAll });
.ms-group-header:hover { background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface)); }
.ms-chevron { font-size: 0.6rem; color: var(--color-text-muted); width: 0.8rem; }
.ms-name { flex: 1; font-weight: 600; 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-status {
@@ -494,7 +495,7 @@ defineExpose({ reload: loadAll });
flex-shrink: 0;
}
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--color-text-muted); font-style: italic; }
.empty-group { padding: 0.4rem 1.4rem; font-size: 0.78rem; color: var(--color-text-muted); }
.state-msg { padding: 1.5rem; text-align: center; font-size: 0.85rem; color: var(--color-text-muted); }
@@ -522,7 +523,7 @@ defineExpose({ reload: loadAll });
padding: 0.2rem 0.55rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
font-weight: 500;
cursor: pointer;
border: 1.5px solid var(--color-border);
background: none;
@@ -575,19 +576,21 @@ defineExpose({ reload: loadAll });
border-radius: 3px;
margin-left: 0.25rem;
}
.btn-delete-task:hover { color: var(--color-danger, #e74c3c); }
.btn-delete-task:hover { color: var(--color-action-destructive); }
.btn-delete-confirm {
background: none;
border: 1px solid var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
border: 1px solid var(--color-action-destructive);
color: var(--color-action-destructive);
font-size: 0.72rem;
font-weight: 600;
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 {
@@ -603,7 +606,7 @@ defineExpose({ reload: loadAll });
.detail-title {
padding: 0.75rem 0.75rem 0.25rem;
font-size: 0.95rem;
font-weight: 600;
font-weight: 500;
margin: 0;
color: var(--color-text);
flex-shrink: 0;
@@ -672,7 +675,7 @@ defineExpose({ reload: loadAll });
}
.task-due.overdue {
color: var(--color-danger, #e74c3c);
font-weight: 600;
font-weight: 500;
}
/* Close detail button */
+23 -21
View File
@@ -10,6 +10,7 @@ import { listEvents, updateEvent, type EventEntry } from "@/api/client";
import EventSlideOver from "@/components/EventSlideOver.vue";
import { useToastStore } from "@/stores/toast";
import { fmtTime, fmtDateTime, fmtDayLabel } from "@/utils/dateFormat";
import { MapPin } from "lucide-vue-next";
const toast = useToastStore();
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
@@ -333,9 +334,7 @@ const upcomingGrouped = computed(() => {
</template>
</div>
<div v-if="ev.location" class="upcoming-card-meta">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
<MapPin :size="16" style="flex-shrink:0" />
{{ ev.location }}
</div>
<div v-if="ev.description" class="upcoming-card-desc">{{ ev.description }}</div>
@@ -367,9 +366,7 @@ const upcomingGrouped = computed(() => {
</template>
</div>
<div v-if="popover.location" class="popover-meta">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="flex-shrink:0;margin-top:1px">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/>
</svg>
<MapPin :size="16" style="flex-shrink:0;margin-top:1px" />
{{ popover.location }}
</div>
<div v-if="popover.description" class="popover-desc">{{ popover.description }}</div>
@@ -429,23 +426,24 @@ const upcomingGrouped = computed(() => {
.cal-title {
font-size: 1.5rem;
font-weight: 700;
font-weight: 500;
margin: 0;
color: var(--color-text, #e8e9f0);
}
/* New event: Moss action-primary — workflow action, not a brand moment */
.btn-new-event {
background: var(--gradient-cta);
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
font-weight: 600;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
transition: background 0.15s;
}
.btn-new-event:hover { opacity: 0.88; }
.btn-new-event:hover { background: var(--color-action-primary-hover); }
.fc-wrapper {
background: var(--color-surface);
@@ -461,8 +459,12 @@ const upcomingGrouped = computed(() => {
font-family: inherit;
}
:deep(.fc-toolbar-title) {
/* FullCalendar renders this as <h2>, which would otherwise pick up the
theme.css h1/h2 → Fraunces rule. At 1.1rem it's below the doc's
"Fraunces only at ≥18px" threshold, so explicit Inter. */
font-family: 'Inter', system-ui, sans-serif;
font-size: 1.1rem;
font-weight: 600;
font-weight: 500;
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
@@ -533,7 +535,7 @@ const upcomingGrouped = computed(() => {
.picker-year-label {
font-size: 0.95rem;
font-weight: 700;
font-weight: 500;
color: var(--color-text, #e8e9f0);
}
@@ -574,9 +576,9 @@ const upcomingGrouped = computed(() => {
background: rgba(255,255,255,0.08);
}
.picker-month.active {
background: rgba(124,58,237,0.22);
background: rgba(91, 74, 138,0.22);
color: var(--color-primary);
font-weight: 700;
font-weight: 500;
}
/* ── Upcoming strip ─────────────────────────────────────────────────────── */
@@ -586,7 +588,7 @@ const upcomingGrouped = computed(() => {
.upcoming-title {
font-size: 1rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.06em;
@@ -602,7 +604,7 @@ const upcomingGrouped = computed(() => {
.upcoming-day-label {
font-size: 0.8rem;
font-weight: 700;
font-weight: 500;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -634,7 +636,7 @@ const upcomingGrouped = computed(() => {
.upcoming-card-accent {
width: 4px;
flex-shrink: 0;
background: var(--ev-color, #7c3aed);
background: var(--ev-color, #5B4A8A);
}
.upcoming-card-body {
@@ -645,7 +647,7 @@ const upcomingGrouped = computed(() => {
.upcoming-card-title {
font-size: 0.9rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text, #e8e9f0);
white-space: nowrap;
overflow: hidden;
@@ -700,7 +702,7 @@ const upcomingGrouped = computed(() => {
.popover-title {
font-size: 0.95rem;
font-weight: 700;
font-weight: 500;
color: var(--color-text, #e8e9f0);
margin-bottom: 0.35rem;
}
@@ -744,7 +746,7 @@ const upcomingGrouped = computed(() => {
border: none;
border-radius: 6px;
font-size: 0.82rem;
font-weight: 600;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
+27 -30
View File
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from "vue-router";
import { useChatStore } from "@/stores/chat";
import { apiGet } from "@/api/client";
import ChatPanel from "@/components/ChatPanel.vue";
import { MoreVertical, Menu, Paperclip, Trash2 } from "lucide-vue-next";
const route = useRoute();
const router = useRouter();
@@ -314,9 +315,7 @@ onUnmounted(() => {
aria-label="List actions"
title="List actions"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.75"/><circle cx="12" cy="12" r="1.75"/><circle cx="12" cy="19" r="1.75"/>
</svg>
<MoreVertical :size="16" />
</button>
<div v-if="sidebarKebabOpen" class="kebab-menu">
<button
@@ -339,6 +338,7 @@ onUnmounted(() => {
:disabled="bulkDeleting"
@click="bulkDelete"
>
<Trash2 :size="16" />
{{ bulkDeleting ? '...' : bulkConfirm ? `Delete ${selectedIds.size}?` : `Delete (${selectedIds.size})` }}
</button>
</div>
@@ -388,11 +388,7 @@ onUnmounted(() => {
<template v-if="store.currentConversation">
<div class="chat-header">
<button class="btn-sidebar-toggle hide-desktop" aria-label="Toggle sidebar" @click="toggleSidebar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
<Menu :size="24" />
</button>
<h2>{{ store.currentConversation.title || "New Chat" }}</h2>
@@ -433,7 +429,7 @@ onUnmounted(() => {
@click="toggleContextSidebar"
:title="contextSidebarOpen ? 'Hide context panel' : 'Show context panel'"
>
<span class="context-icon">📎</span>
<Paperclip class="context-icon" :size="16" />
<span class="context-label">Context</span>
<span class="context-count-badge">{{ contextCount }}</span>
</button>
@@ -446,9 +442,7 @@ onUnmounted(() => {
aria-label="Conversation actions"
title="Conversation actions"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.75"/><circle cx="12" cy="12" r="1.75"/><circle cx="12" cy="19" r="1.75"/>
</svg>
<MoreVertical :size="16" />
</button>
<div v-if="headerKebabOpen" class="kebab-menu kebab-menu--right">
<button
@@ -470,11 +464,7 @@ onUnmounted(() => {
<div v-else class="no-conversation">
<button class="btn-sidebar-toggle no-conv-toggle hide-desktop" @click="toggleSidebar">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
<Menu :size="24" />
</button>
<p>Select a conversation or start a new chat.</p>
<button class="btn-new-conv" @click="newConversation">
@@ -520,7 +510,7 @@ onUnmounted(() => {
.btn-new-conv--full { width: 100%; }
.btn-new-conv:hover {
opacity: 0.9;
box-shadow: 0 0 14px rgba(129, 140, 248, 0.35);
box-shadow: 0 0 14px rgba(91, 74, 138, 0.35);
}
.sidebar-search-row {
@@ -556,22 +546,29 @@ onUnmounted(() => {
.bulk-count { font-size: 0.75rem; color: var(--color-text-muted); flex-shrink: 0; }
.bulk-link {
background: none; border: none; color: var(--color-primary);
background: none; border: none; color: var(--color-text-secondary);
font-size: 0.75rem; cursor: pointer; padding: 0; text-decoration: underline;
}
.bulk-link:hover { color: var(--color-text); }
/* Destructive action — Oxblood per Hybrid rule, paired with Trash2 icon */
.bulk-delete-btn {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-left: auto;
background: none;
border: 1px solid var(--color-danger, #e74c3c);
color: var(--color-danger, #e74c3c);
border-radius: 4px;
border: 1px solid var(--color-action-destructive);
color: var(--color-action-destructive);
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 600;
font-weight: 500;
padding: 0.2rem 0.55rem;
cursor: pointer;
}
.bulk-delete-btn:hover:not(:disabled) { background: var(--color-action-destructive); color: #fff; }
.bulk-delete-btn:disabled { opacity: 0.5; cursor: default; }
.bulk-delete-btn.confirm { background: var(--color-danger, #e74c3c); color: #fff; }
.bulk-delete-btn.confirm { background: var(--color-action-destructive-hover); color: #fff; border-color: var(--color-action-destructive-hover); }
.conv-checkbox {
flex-shrink: 0;
@@ -591,7 +588,7 @@ onUnmounted(() => {
}
.conv-group-label {
font-size: 0.68rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
@@ -612,7 +609,7 @@ onUnmounted(() => {
border-left: 3px solid transparent;
transition: background 0.15s, border-color 0.15s;
}
.conv-item:hover { background: rgba(99,102,241,0.05); border-left-color: var(--color-primary); }
.conv-item:hover { background: rgba(91, 74, 138,0.05); border-left-color: var(--color-primary); }
.conv-item.active {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
@@ -625,7 +622,7 @@ onUnmounted(() => {
background: none; border: none; color: var(--color-text-muted);
cursor: pointer; font-size: 1.2rem; padding: 0 0.25rem; line-height: 1;
}
.btn-delete-conv:hover { color: var(--color-danger, #e74c3c); }
.btn-delete-conv:hover { color: var(--color-action-destructive); }
.chat-main {
flex: 1;
@@ -704,7 +701,7 @@ onUnmounted(() => {
50% {
border-color: var(--color-primary);
color: var(--color-primary);
box-shadow: 0 0 6px rgba(99, 102, 241, 0.4);
box-shadow: 0 0 6px rgba(91, 74, 138, 0.4);
}
}
.scope-dot { font-size: 0.6rem; }
@@ -732,7 +729,7 @@ onUnmounted(() => {
cursor: pointer;
}
.scope-option:hover { background: var(--color-bg-secondary); }
.scope-option.active { color: var(--color-primary); font-weight: 600; }
.scope-option.active { color: var(--color-primary); font-weight: 500; }
/* Context toggle button (header) */
.btn-context-toggle {
@@ -759,7 +756,7 @@ onUnmounted(() => {
.context-icon { font-size: 0.85rem; line-height: 1; }
.context-count-badge {
font-size: 0.65rem;
font-weight: 700;
font-weight: 500;
background: var(--color-bg);
padding: 0.05rem 0.35rem;
border-radius: 8px;
+7 -6
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { X } from "lucide-vue-next";
import { ref, onMounted, onUnmounted, watch, computed } from "vue";
import { useRouter } from "vue-router";
import { forceSimulation, forceLink, forceManyBody, forceX, forceY, forceCollide, SimulationNodeDatum, SimulationLinkDatum, Simulation } from "d3-force";
@@ -543,7 +544,7 @@ onUnmounted(() => {
:to="peekNode.type === 'task' ? `/tasks/${peekNode.id}/edit` : `/notes/${peekNode.id}/edit`"
class="peek-link"
>Edit</router-link>
<button class="peek-close" @click="closePeek" title="Close (Esc)"></button>
<button class="peek-close" @click="closePeek" title="Close (Esc)"><X :size="16" /></button>
</div>
</div>
@@ -758,7 +759,7 @@ onUnmounted(() => {
.tooltip-title {
font-size: 0.875rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text);
margin-bottom: 0.25rem;
}
@@ -820,7 +821,7 @@ onUnmounted(() => {
.peek-type-badge {
font-size: 0.68rem;
font-weight: 600;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.45rem;
@@ -859,7 +860,7 @@ onUnmounted(() => {
margin: 0;
padding: 0.75rem 0.75rem 0.4rem;
font-size: 1rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text);
flex-shrink: 0;
}
@@ -897,7 +898,7 @@ onUnmounted(() => {
.peek-linked-label {
font-size: 0.7rem;
font-weight: 600;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
@@ -932,7 +933,7 @@ onUnmounted(() => {
.peek-linked-type {
font-size: 0.65rem;
font-weight: 700;
font-weight: 500;
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
+14 -14
View File
@@ -569,7 +569,7 @@ function formatUpcomingTime(event: EventEntry): string {
border-radius: var(--radius-lg);
padding: 1.25rem 1.5rem;
margin-bottom: 1.75rem;
box-shadow: 0 2px 16px rgba(124, 58, 237, 0.08), 0 1px 4px rgba(0, 0, 0, 0.06);
box-shadow: 0 2px 16px rgba(91, 74, 138, 0.08), 0 1px 4px rgba(0, 0, 0, 0.06);
}
.hero-top {
display: flex;
@@ -585,14 +585,14 @@ function formatUpcomingTime(event: EventEntry): string {
}
.hero-label {
font-size: 0.7rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
}
.hero-title {
font-size: 1.4rem;
font-weight: 700;
font-weight: 500;
color: var(--color-text);
text-decoration: none;
letter-spacing: -0.02em;
@@ -610,22 +610,22 @@ function formatUpcomingTime(event: EventEntry): string {
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
font-weight: 600;
font-weight: 500;
white-space: nowrap;
box-shadow: 0 1px 6px rgba(124, 58, 237, 0.3);
box-shadow: 0 1px 6px rgba(91, 74, 138, 0.3);
transition: opacity 0.15s, box-shadow 0.15s;
flex-shrink: 0;
}
.btn-workspace-hero:hover {
opacity: 0.9;
box-shadow: 0 3px 14px rgba(124, 58, 237, 0.45);
box-shadow: 0 3px 14px rgba(91, 74, 138, 0.45);
}
.hero-milestones { margin-bottom: 0.75rem; }
.hero-section-label {
font-size: 0.68rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-text-muted);
@@ -688,7 +688,7 @@ function formatUpcomingTime(event: EventEntry): string {
justify-content: space-between;
margin-bottom: 0.75rem;
}
.section-header h2 { margin: 0; font-size: 1rem; font-weight: 600; }
.section-header h2 { margin: 0; font-size: 1rem; font-weight: 500; }
.see-all { font-size: 0.85rem; color: var(--color-primary); text-decoration: none; }
.see-all:hover { text-decoration: underline; }
@@ -718,7 +718,7 @@ function formatUpcomingTime(event: EventEntry): string {
}
.project-card-title {
font-size: 0.9rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text);
text-decoration: none;
flex: 1;
@@ -760,8 +760,8 @@ function formatUpcomingTime(event: EventEntry): string {
font-weight: 500;
}
.urgency-in-progress {
background: color-mix(in srgb, #7c3aed 15%, transparent);
color: #7c3aed;
background: color-mix(in srgb, #5B4A8A 15%, transparent);
color: #5B4A8A;
}
.urgency-todo {
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
@@ -771,7 +771,7 @@ function formatUpcomingTime(event: EventEntry): string {
background: color-mix(in srgb, #22c55e 12%, transparent);
color: #16a34a;
}
.urgency-loading { color: var(--color-text-muted); font-style: italic; }
.urgency-loading { color: var(--color-text-muted); }
/* ─── Inbox ──────────────────────────────────────────────────── */
.inbox-section {
@@ -799,7 +799,7 @@ function formatUpcomingTime(event: EventEntry): string {
.inbox-header h2 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.4rem;
@@ -911,7 +911,7 @@ function formatUpcomingTime(event: EventEntry): string {
}
.upcoming-event-title {
font-size: 0.85rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
+17 -61
View File
@@ -4,6 +4,7 @@ import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
import { useChatStore } from '@/stores/chat'
import ChatPanel from '@/components/ChatPanel.vue'
import WeatherCard from '@/components/WeatherCard.vue'
import { RotateCcw } from 'lucide-vue-next'
import {
apiGet,
apiPost,
@@ -129,10 +130,15 @@ function formatEventTime(ev: EventEntry): string {
async function loadEvents() {
try {
const now = new Date()
const end = new Date(now)
// Window: today 00:00 → 14 days out. Matches the prep's framing — events
// earlier today (already past) still surface here, grouped under "Today",
// so the right rail aligns with what the prep references.
const start = new Date()
start.setHours(0, 0, 0, 0)
const end = new Date(start)
end.setDate(end.getDate() + 14)
upcomingEvents.value = await listEvents(now.toISOString(), end.toISOString())
end.setHours(23, 59, 59, 999)
upcomingEvents.value = await listEvents(start.toISOString(), end.toISOString())
} catch { /* silent */ }
}
@@ -273,7 +279,9 @@ onMounted(async () => {
:disabled="refreshingWeather"
@click="refreshWeather"
title="Refresh weather"
></button>
>
<RotateCcw :size="16" />
</button>
</div>
<WeatherCard
:weather="weatherData[selectedWeatherIdx]"
@@ -336,9 +344,8 @@ onMounted(async () => {
}
.journal-header-left { display: flex; align-items: baseline; gap: 0.75rem; }
.journal-title {
font-family: 'Fraunces', Georgia, serif;
/* h1 inherits Fraunces from theme.css; weight 500 follows the doc's "two weights only" rule */
font-size: 1.3rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
@@ -434,7 +441,7 @@ onMounted(async () => {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
font-weight: 600;
font-weight: 500;
}
.events-section { padding: 0.75rem 1rem; border-top: 1px solid var(--color-border); }
@@ -444,7 +451,7 @@ onMounted(async () => {
.events-day-group { display: flex; flex-direction: column; gap: 0.2rem; }
.events-day-label {
font-size: 0.72rem;
font-weight: 700;
font-weight: 500;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
@@ -457,70 +464,19 @@ onMounted(async () => {
flex-shrink: 0; margin-top: 5px;
}
.event-body { display: flex; flex-direction: column; gap: 0.05rem; min-width: 0; }
.event-title { font-size: 0.82rem; font-weight: 600; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.event-title { font-size: 0.82rem; font-weight: 500; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.event-time { font-size: 0.72rem; color: var(--color-text-muted); }
.event-loc { font-size: 0.7rem; color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.news-section {
flex: 1;
overflow-y: auto;
padding: 0.75rem 1rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.panel-label {
font-size: 0.72rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-primary);
flex-shrink: 0;
}
.panel-label-row { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.news-count { font-size: 0.72rem; color: var(--color-text-muted); }
.panel-empty { font-size: 0.82rem; color: var(--color-text-muted); padding: 0.5rem 0; }
.news-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 0.65rem 0.85rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
flex-shrink: 0;
}
.news-card-meta { display: flex; align-items: center; gap: 0.5rem; }
.news-source { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--color-primary); }
.news-date { font-size: 0.72rem; color: var(--color-text-muted); }
.news-title { font-size: 0.88rem; font-weight: 600; color: var(--color-text); line-height: 1.35; text-decoration: none; margin: 0; }
a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
.news-snippet {
font-size: 0.78rem;
color: var(--color-text-muted);
line-height: 1.45;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.news-reactions { display: flex; gap: 0.3rem; margin-top: 0.15rem; }
.reaction-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.1rem 0.35rem;
cursor: pointer;
font-size: 0.82rem;
line-height: 1.4;
opacity: 0.55;
transition: opacity 0.15s, border-color 0.15s;
}
.reaction-btn:hover { opacity: 1; border-color: var(--color-primary); }
.reaction-btn.active { opacity: 1; border-color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 12%, transparent); }
@media (max-width: 900px) {
.journal-shell { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
+55 -47
View File
@@ -7,6 +7,20 @@ import { useChatStore } from "@/stores/chat";
import GraphView from "@/views/GraphView.vue";
import ChatPanel from "@/components/ChatPanel.vue";
import ChatInputBar from "@/components/ChatInputBar.vue";
import {
FileText,
CheckSquare,
User,
MapPin,
List,
Search,
Share2,
ChevronLeft,
ChevronRight,
ChevronUp,
ChevronDown,
X,
} from "lucide-vue-next";
const router = useRouter();
const chatStore = useChatStore();
@@ -426,23 +440,23 @@ onUnmounted(() => {
</button>
<div v-if="newNoteMenuOpen" class="new-note-menu">
<button @click="createNew('note')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<FileText :size="16" />
Note
</button>
<button @click="createNew('task')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
<CheckSquare :size="16" />
Task
</button>
<button @click="createNew('person')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<User :size="16" />
Person
</button>
<button @click="createNew('place')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
<MapPin :size="16" />
Place
</button>
<button @click="createNew('list')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
<List :size="16" />
List
</button>
</div>
@@ -493,7 +507,7 @@ onUnmounted(() => {
<!-- Toolbar -->
<div class="knowledge-toolbar">
<div class="search-wrap">
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<Search class="search-icon" :size="16" />
<input
v-model="searchQuery"
@input="onSearchInput"
@@ -509,10 +523,7 @@ onUnmounted(() => {
<option value="type">By type</option>
</select>
<button class="btn-graph" :class="{ active: graphOpen }" @click="toggleGraph" title="Toggle graph view">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
<path d="m8.59 13.51 6.83 3.98M15.41 6.51l-6.82 3.98"/>
</svg>
<Share2 :size="16" />
Graph
</button>
</div>
@@ -630,12 +641,12 @@ onUnmounted(() => {
@click="toggleGraphExpand"
:title="graphExpanded ? 'Narrow panel' : 'Expand panel'"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline v-if="graphExpanded" points="15 18 9 12 15 6"/>
<polyline v-else points="9 18 15 12 9 6"/>
</svg>
<ChevronLeft v-if="graphExpanded" :size="16" />
<ChevronRight v-else :size="16" />
</button>
<button class="btn-icon-sm" @click="toggleGraph" title="Close graph">
<X :size="16" />
</button>
<button class="btn-icon-sm" @click="toggleGraph" title="Close graph"></button>
</div>
</div>
<div class="graph-embed">
@@ -656,12 +667,12 @@ onUnmounted(() => {
@click="chatCollapsed = !chatCollapsed"
:title="chatCollapsed ? 'Expand' : 'Collapse'"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline v-if="chatCollapsed" points="18 15 12 9 6 15"/>
<polyline v-else points="6 9 12 15 18 9"/>
</svg>
<ChevronUp v-if="chatCollapsed" :size="16" />
<ChevronDown v-else :size="16" />
</button>
<button class="btn-icon-sm" @click="closeChat" title="Close chat">
<X :size="16" />
</button>
<button class="btn-icon-sm" @click="closeChat" title="Close chat"></button>
</div>
</div>
@@ -720,17 +731,17 @@ onUnmounted(() => {
gap: 5px;
padding: 3px 10px;
border-radius: 20px;
background: rgba(124, 58, 237, 0.1);
border: 1px solid rgba(124, 58, 237, 0.2);
background: rgba(91, 74, 138, 0.1);
border: 1px solid rgba(91, 74, 138, 0.2);
color: var(--color-text);
text-decoration: none;
transition: background 0.15s;
}
.today-event-chip:hover { background: rgba(124, 58, 237, 0.18); }
.today-event-chip:hover { background: rgba(91, 74, 138, 0.18); }
.chip-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: #7c3aed;
background: #5B4A8A;
flex-shrink: 0;
}
.chip-date { color: var(--color-muted); font-size: 0.78rem; }
@@ -775,18 +786,16 @@ onUnmounted(() => {
content: '· · ·';
display: block;
text-align: center;
color: rgba(124, 58, 237, 0.3);
color: rgba(91, 74, 138, 0.3);
font-size: 0.9rem;
letter-spacing: 0.4em;
padding: 4px 0 12px;
}
.filter-label {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.72rem;
letter-spacing: 0.02em;
font-size: 0.95rem;
color: var(--color-primary);
margin-bottom: 6px;
margin-bottom: 8px;
padding: 0 4px;
}
/* ── New item button ─────────────────────────────────────── */
@@ -806,7 +815,7 @@ onUnmounted(() => {
color: #fff;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
font-weight: 500;
transition: box-shadow 0.15s;
}
.btn-new-note:hover { box-shadow: var(--glow-cta-hover); }
@@ -891,7 +900,7 @@ onUnmounted(() => {
flex-shrink: 0;
}
.filter-btn.active .filter-count {
background: rgba(124, 58, 237, 0.2);
background: rgba(91, 74, 138, 0.2);
color: var(--color-primary);
}
.filter-tag { font-size: 0.78rem; }
@@ -965,7 +974,7 @@ onUnmounted(() => {
.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(124, 58, 237, 0.35);
border-color: rgba(91, 74, 138, 0.35);
color: var(--color-primary);
}
@@ -998,12 +1007,12 @@ onUnmounted(() => {
}
.k-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 28px rgba(124, 58, 237, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: rgba(124, 58, 237, 0.35);
box-shadow: 0 8px 28px rgba(91, 74, 138, 0.25), 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: rgba(91, 74, 138, 0.35);
}
/* Type-specific card DNA */
.k-card--note { border-color: rgba(124, 58, 237, 0.20); }
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
.k-card--person { border-color: rgba(16, 185, 129, 0.18); }
.k-card--place { border-color: rgba(245, 158, 11, 0.18); }
@@ -1022,7 +1031,7 @@ onUnmounted(() => {
}
.k-card--note::before {
right: 0;
background: linear-gradient(90deg, #7c3aed, #a78bfa);
background: linear-gradient(90deg, #5B4A8A, #7A6DA8);
}
.k-card--task::before {
right: 0;
@@ -1056,11 +1065,11 @@ onUnmounted(() => {
font-size: 0.68rem;
padding: 2px 7px;
border-radius: 10px;
font-weight: 600;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge--note { background: rgba(99,102,241,0.15); color: #a78bfa; }
.badge--note { background: rgba(91, 74, 138,0.15); color: #7A6DA8; }
.badge--person { background: rgba(16,185,129,0.15); color: #34d399; }
.badge--place { background: rgba(245,158,11,0.15); color: #fbbf24; }
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
@@ -1068,7 +1077,7 @@ onUnmounted(() => {
.k-card-body { flex: 1; padding-right: 40px; }
.k-card-title {
font-weight: 600;
font-weight: 500;
font-size: 0.92rem;
margin-bottom: 5px;
line-height: 1.3;
@@ -1165,7 +1174,7 @@ onUnmounted(() => {
background: rgba(255,255,255,0.05);
color: var(--color-muted);
}
.k-card-date { font-size: 0.72rem; color: var(--color-accent-warm); white-space: nowrap; opacity: 0.7; }
.k-card-date { font-size: 0.72rem; color: var(--color-text-secondary); white-space: nowrap; opacity: 0.7; }
/* ── Task card ──────────────────────────────────────────── */
.k-card-task {
@@ -1182,7 +1191,7 @@ onUnmounted(() => {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 600;
font-weight: 500;
}
.status--todo { background: var(--color-status-todo-bg); color: var(--color-status-todo); }
.status--in_progress { background: var(--color-status-in-progress-bg); color: var(--color-status-in-progress); }
@@ -1193,7 +1202,7 @@ onUnmounted(() => {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 600;
font-weight: 500;
}
.priority--low { background: var(--color-priority-low-bg); color: var(--color-priority-low); }
.priority--normal { background: var(--color-priority-medium-bg); color: var(--color-priority-medium); }
@@ -1201,7 +1210,7 @@ onUnmounted(() => {
.task-due {
font-size: 0.78rem;
color: var(--color-accent-warm);
color: var(--color-text-secondary);
}
.task-overdue {
color: var(--color-overdue);
@@ -1223,9 +1232,8 @@ onUnmounted(() => {
.empty-hint { font-size: 0.85rem; opacity: 0.7; }
.empty-narrator {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 1rem;
color: var(--color-accent-warm);
color: var(--color-text-secondary);
opacity: 0.85;
}
@@ -1261,7 +1269,7 @@ onUnmounted(() => {
justify-content: space-between;
padding: 10px 14px;
font-size: 0.85rem;
font-weight: 600;
font-weight: 500;
border-bottom: 1px solid var(--color-border, rgba(255,255,255,0.06));
flex-shrink: 0;
}
@@ -1324,7 +1332,7 @@ onUnmounted(() => {
}
.minichat-title {
font-size: 0.82rem;
font-weight: 600;
font-weight: 500;
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
+6 -6
View File
@@ -21,6 +21,7 @@ import MilestoneSelector from "@/components/MilestoneSelector.vue";
import DiffView from "@/components/DiffView.vue";
import VersionHistorySection from "@/components/VersionHistorySection.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { Trash2 } from "lucide-vue-next";
const route = useRoute();
const router = useRouter();
@@ -449,7 +450,9 @@ onUnmounted(() => assist.clearSelection());
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<button v-if="isEditing" class="btn-delete" @click="remove">Delete</button>
<button v-if="isEditing" class="btn-delete" @click="remove">
<Trash2 :size="16" /> Delete
</button>
<WordCount :body="body" />
</div>
<input
@@ -892,7 +895,6 @@ onUnmounted(() => assist.clearSelection());
.stream-label {
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
}
.stream-preview {
@@ -1013,7 +1015,7 @@ onUnmounted(() => assist.clearSelection());
.assist-section-title {
font-size: 0.78rem;
font-weight: 700;
font-weight: 500;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -1033,8 +1035,7 @@ onUnmounted(() => assist.clearSelection());
}
.ef-label {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.78rem;
font-size: 0.92rem;
color: var(--color-primary);
}
.ef-input {
@@ -1067,7 +1068,6 @@ onUnmounted(() => assist.clearSelection());
border: none;
color: var(--color-primary);
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.85rem;
cursor: pointer;
padding: 4px 0;
+24 -25
View File
@@ -9,6 +9,7 @@ import type { Note } from "@/types/note";
import TagPill from "@/components/TagPill.vue";
import TableOfContents from "@/components/TableOfContents.vue";
import ShareDialog from "@/components/ShareDialog.vue";
import { Clock, Pencil, Link as LinkIcon } from "lucide-vue-next";
const route = useRoute();
const router = useRouter();
@@ -238,12 +239,12 @@ async function convertToTask() {
<h1 class="note-title">{{ store.currentNote.title || "Untitled" }}</h1>
<p class="meta">
<span class="meta-item">
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
<Clock :size="16" />
Updated {{ relativeTime(store.currentNote.updated_at) }}
</span>
<span class="meta-sep" aria-hidden="true">·</span>
<span class="meta-item">
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
<Pencil :size="16" />
Created {{ relativeTime(store.currentNote.created_at) }}
</span>
</p>
@@ -265,7 +266,7 @@ async function convertToTask() {
<div v-if="backlinks.length" class="backlinks">
<h3 class="backlinks-heading">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
<LinkIcon :size="16" />
Backlinks
<span class="backlinks-count">{{ backlinks.length }}</span>
</h3>
@@ -344,41 +345,39 @@ async function convertToTask() {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Edit: Moss action-primary — switching from view to edit is operating
the software, not a brand moment. */
.btn-edit {
display: inline-flex;
align-items: center;
padding: 0.45rem 1.1rem;
border: none;
border-radius: var(--radius-sm);
background: var(--gradient-cta);
background: var(--color-action-primary);
color: #fff;
text-decoration: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
box-shadow: var(--glow-cta);
transition: box-shadow 0.15s, opacity 0.15s;
font-weight: 500;
transition: background 0.15s;
}
.btn-edit:hover {
box-shadow: var(--glow-cta-hover);
opacity: 0.95;
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-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
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-primary);
color: #fff;
border-color: var(--color-primary);
}
.btn-convert:hover { background: var(--color-action-secondary-hover); }
.btn-convert:disabled {
opacity: 0.6;
cursor: default;
@@ -386,21 +385,21 @@ async function convertToTask() {
.btn-share {
padding: 0.3rem 0.75rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
background: var(--color-action-secondary);
border: none;
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
color: #fff;
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
transition: border-color 0.15s, color 0.15s;
transition: background 0.15s;
}
.btn-share:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-share:hover { background: var(--color-action-secondary-hover); }
.note-title {
font-family: "Fraunces", Georgia, serif;
font-size: 2rem;
font-weight: 700;
font-weight: 500;
line-height: 1.2;
margin: 0.25rem 0 0.5rem;
color: var(--color-text);
@@ -439,7 +438,7 @@ async function convertToTask() {
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
@@ -481,7 +480,7 @@ async function convertToTask() {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
font-weight: 500;
padding: 0.1rem 0.45rem;
border-radius: 999px;
flex-shrink: 0;
+12 -9
View File
@@ -283,18 +283,21 @@ function overallPct(project: Project): { total: number; pct: number } {
margin: 0;
}
/* Moss action-primary per Hybrid — list-view utility action,
not a brand moment. Empty-state .empty-action below keeps accent. */
.btn-primary {
padding: 0.45rem 1rem;
background: var(--color-primary);
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 {
opacity: 0.9;
background: var(--color-action-primary-hover);
}
.filter-tabs {
@@ -321,7 +324,7 @@ function overallPct(project: Project): { total: number; pct: number } {
.tab-btn.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 600;
font-weight: 500;
}
.loading-msg,
@@ -336,7 +339,7 @@ function overallPct(project: Project): { total: number; pct: number } {
.empty-state-rich { text-align: center; padding: 3rem 1rem; color: var(--color-text-muted); }
.empty-icon { font-size: 2.5rem; margin-bottom: 0.75rem; opacity: 0.3; }
.empty-title { font-size: 1rem; font-weight: 600; color: var(--color-text-secondary); margin: 0 0 0.35rem; }
.empty-title { font-size: 1rem; font-weight: 500; color: var(--color-text-secondary); margin: 0 0 0.35rem; }
.empty-sub { font-size: 0.85rem; margin: 0 0 1rem; }
.empty-action { display: inline-block; padding: 0.4rem 1rem; border: 1px solid var(--color-primary); border-radius: var(--radius-sm); color: var(--color-primary); background: none; cursor: pointer; font-size: 0.85rem; transition: background 0.15s, color 0.15s; }
.empty-action:hover { background: var(--color-primary); color: #fff; }
@@ -389,7 +392,7 @@ function overallPct(project: Project): { total: number; pct: number } {
}
.project-title {
font-size: 1rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text);
min-width: 0;
flex: 1;
@@ -398,7 +401,7 @@ function overallPct(project: Project): { total: number; pct: number } {
.status-badge {
font-size: 0.7rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.45rem;
@@ -426,7 +429,7 @@ function overallPct(project: Project): { total: number; pct: number } {
line-height: 1.4;
}
.field-label {
font-weight: 600;
font-weight: 500;
color: var(--color-text-secondary);
}
@@ -462,7 +465,7 @@ function overallPct(project: Project): { total: number; pct: number } {
flex-shrink: 0;
min-width: 2.5rem;
text-align: right;
font-weight: 600;
font-weight: 500;
}
.milestone-bars {
@@ -545,7 +548,7 @@ function overallPct(project: Project): { total: number; pct: number } {
}
.modal-field label {
font-size: 0.875rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text);
}
.required {
+67 -45
View File
@@ -5,6 +5,16 @@ import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import { relativeTime } from "@/composables/useRelativeTime";
import ShareDialog from "@/components/ShareDialog.vue";
import {
LayoutGrid,
Clock,
FileText,
ChevronRight,
ChevronDown,
Pencil,
Trash2,
Check,
} from "lucide-vue-next";
interface Milestone {
id: number;
@@ -330,7 +340,7 @@ async function confirmDelete() {
<router-link to="/projects" class="btn-back"> Projects</router-link>
<div class="page-header-actions">
<router-link v-if="project" :to="`/workspace/${project.id}`" class="btn-workspace">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z"/></svg>
<LayoutGrid :size="16" />
Workspace
</router-link>
<button v-if="project" class="btn-share" @click="showShare = true">Share</button>
@@ -359,7 +369,7 @@ async function confirmDelete() {
</div>
<p v-if="project.goal" class="project-goal">{{ project.goal }}</p>
<p v-if="project.summary?.last_activity" class="project-activity">
<svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor" aria-hidden="true"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
<Clock :size="16" />
Active {{ relativeTime(project.summary.last_activity) }}
</p>
</div>
@@ -382,7 +392,7 @@ async function confirmDelete() {
<span class="stat-label">done</span>
</div>
<div class="stat-chip stat-notes">
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" aria-hidden="true" style="opacity:0.6"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg>
<FileText :size="16" style="opacity:0.6" />
<span class="stat-val">{{ project.summary.note_count }}</span>
<span class="stat-label">notes</span>
</div>
@@ -420,7 +430,7 @@ async function confirmDelete() {
<div class="tab-bar">
<button :class="['tab-btn', { active: activeTab === 'tasks' }]" @click="activeTab = 'tasks'">
Tasks
<span v-if="project.summary" class="tab-count">{{ project.summary.task_counts.todo + project.summary.task_counts.in_progress + project.summary.task_counts.done }}</span>
<span v-if="project.summary" class="tab-count">{{ (project.summary.task_counts.todo ?? 0) + (project.summary.task_counts.in_progress ?? 0) + (project.summary.task_counts.done ?? 0) }}</span>
</button>
<button :class="['tab-btn', { active: activeTab === 'notes' }]" @click="activeTab = 'notes'">
Notes
@@ -463,7 +473,8 @@ async function confirmDelete() {
@click="group.milestone && renamingMilestoneId !== group.milestone.id && toggleMilestoneCollapse(group.milestone.id)"
>
<span class="ms-chevron" v-if="group.milestone">
<svg viewBox="0 0 24 24" width="10" height="10" fill="currentColor"><path :d="collapsedMilestones.has(group.milestone.id) ? 'M8 5l8 7-8 7V5z' : 'M5 8l7 8 7-8H5z'"/></svg>
<ChevronRight v-if="collapsedMilestones.has(group.milestone.id)" :size="16" />
<ChevronDown v-else :size="16" />
</span>
<template v-if="group.milestone && renamingMilestoneId === group.milestone.id">
<input
@@ -485,10 +496,10 @@ async function confirmDelete() {
<span class="ms-pct">{{ group.milestone.pct }}%</span>
<div class="ms-actions" @click.stop>
<button class="ms-action-btn" title="Rename" @click="startRenameMilestone(group.milestone)">
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
<Pencil :size="16" />
</button>
<button class="ms-action-btn ms-action-delete" title="Delete" @click="deletingMilestone = group.milestone">
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg>
<Trash2 :size="16" />
</button>
</div>
</template>
@@ -554,7 +565,7 @@ async function confirmDelete() {
title="Mark as Done"
:disabled="advancingTaskId === task.id"
@click="advanceTaskStatus(task, $event)"
></button>
><Check :size="16" /></button>
</div>
</router-link>
<p v-if="!group.tasks.filter(t => t.status === 'in_progress').length" class="col-empty">No tasks</p>
@@ -598,7 +609,7 @@ async function confirmDelete() {
</div>
<template v-else>
<router-link v-for="note in notes" :key="note.id" :to="`/notes/${note.id}`" class="note-row">
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" class="note-icon" aria-hidden="true"><path d="M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"/></svg>
<FileText class="note-icon" :size="16" />
<span class="note-title">{{ note.title || "Untitled" }}</span>
<span class="note-date">{{ relativeTime(note.updated_at) }}</span>
</router-link>
@@ -682,6 +693,9 @@ async function confirmDelete() {
}
.btn-back:hover { border-color: var(--color-primary); color: var(--color-primary); }
/* Open Workspace: brand-moment CTA — keep accent gradient. Workspace is
the project's "central feature moment" — entering the focused workspace
is a Scribe-flavored action, not a plain operation. */
.btn-workspace {
display: inline-flex;
align-items: center;
@@ -692,38 +706,41 @@ async function confirmDelete() {
border: none;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 600;
font-weight: 500;
text-decoration: none;
box-shadow: var(--glow-cta);
transition: box-shadow 0.15s, opacity 0.15s;
}
.btn-workspace:hover { box-shadow: var(--glow-cta-hover); opacity: 0.95; color: #fff; }
/* Share: Bronze action-secondary — alternate path */
.btn-share {
padding: 0.4rem 0.8rem;
background: none;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
background: var(--color-action-secondary);
border: none;
color: #fff;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
transition: border-color 0.15s, color 0.15s;
transition: background 0.15s;
}
.btn-share:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-share:hover { background: var(--color-action-secondary-hover); }
/* Delete project: Oxblood action-destructive ghost — outline form since
the actual confirm modal carries the filled destructive treatment */
.btn-danger-outline {
padding: 0.4rem 0.8rem;
background: none;
border: 1px solid var(--color-border);
color: var(--color-text-muted);
border: 1px solid var(--color-action-destructive);
color: var(--color-action-destructive);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
transition: border-color 0.15s, color 0.15s;
transition: background 0.15s, color 0.15s;
}
.btn-danger-outline:hover { border-color: var(--color-danger, #e74c3c); color: var(--color-danger, #e74c3c); }
.btn-danger-outline:hover { background: var(--color-action-destructive); color: #fff; }
.error-msg { color: var(--color-danger); font-size: 0.9rem; }
@@ -739,7 +756,7 @@ async function confirmDelete() {
.project-title-input {
flex: 1;
font-size: 1.75rem;
font-weight: 700;
font-weight: 500;
font-family: "Fraunces", Georgia, serif;
color: var(--color-text);
background: transparent;
@@ -751,11 +768,11 @@ async function confirmDelete() {
transition: border-color 0.15s;
}
.project-title-input:focus { border-bottom-color: var(--color-primary); }
.project-title-input::placeholder { color: var(--color-text-muted); font-weight: 400; font-style: italic; }
.project-title-input::placeholder { color: var(--color-text-muted); font-weight: 400; }
.status-badge {
font-size: 0.68rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.18rem 0.55rem;
@@ -763,7 +780,7 @@ async function confirmDelete() {
flex-shrink: 0;
}
.status-active { background: color-mix(in srgb, var(--color-success) 14%, transparent); color: var(--color-success); border: 1px solid color-mix(in srgb, var(--color-success) 30%, transparent); }
.status-paused { background: color-mix(in srgb, var(--color-accent-warm) 14%, transparent); color: var(--color-accent-warm); border: 1px solid color-mix(in srgb, var(--color-accent-warm) 30%, transparent); }
.status-paused { background: color-mix(in srgb, var(--color-warning) 14%, transparent); color: var(--color-warning); border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent); }
.status-completed { background: color-mix(in srgb, var(--color-primary) 14%, transparent); color: var(--color-primary); border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent); }
.status-archived { background: color-mix(in srgb, var(--color-text-muted) 14%, transparent); color: var(--color-text-muted); border: 1px solid color-mix(in srgb, var(--color-text-muted) 30%, transparent); }
@@ -799,7 +816,7 @@ async function confirmDelete() {
font-size: 0.82rem;
border: 1px solid;
}
.stat-val { font-weight: 700; font-size: 0.9rem; }
.stat-val { font-weight: 500; font-size: 0.9rem; }
.stat-label { color: inherit; opacity: 0.8; }
.stat-dot {
@@ -840,7 +857,7 @@ async function confirmDelete() {
.panel-heading {
margin: 0 0 0.1rem;
font-size: 0.72rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
@@ -848,7 +865,7 @@ async function confirmDelete() {
.edit-field { display: flex; flex-direction: column; gap: 0.3rem; }
.edit-label {
font-size: 0.75rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.03em;
@@ -867,21 +884,21 @@ async function confirmDelete() {
.edit-input:focus, .edit-textarea:focus, .edit-select:focus { outline: none; border-color: var(--color-primary); }
.edit-textarea { resize: vertical; }
/* Save panel: Moss action-primary per Hybrid rule */
.btn-save-panel {
padding: 0.45rem 0.9rem;
background: var(--gradient-cta);
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
font-weight: 500;
font-family: inherit;
width: 100%;
box-shadow: var(--glow-soft);
transition: box-shadow 0.15s, opacity 0.15s;
transition: background 0.15s;
}
.btn-save-panel:hover:not(:disabled) { box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4); opacity: 0.95; }
.btn-save-panel:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.btn-save-panel:disabled { opacity: 0.45; cursor: default; }
/* ── Content area ────────────────────────────────────────────── */
@@ -908,10 +925,10 @@ async function confirmDelete() {
transition: color 0.15s;
}
.tab-btn:hover { color: var(--color-primary); }
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); font-weight: 600; }
.tab-btn.active { color: var(--color-primary); border-bottom-color: var(--color-primary); font-weight: 500; }
.tab-count {
font-size: 0.7rem;
font-weight: 600;
font-weight: 500;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 999px;
@@ -953,27 +970,32 @@ async function confirmDelete() {
font-family: inherit;
}
.milestone-title-input:focus { outline: none; border-color: var(--color-primary); }
/* Milestone confirm: Moss action-primary; Cancel: Bronze action-secondary */
.btn-ms-confirm {
padding: 0.3rem 0.65rem;
background: var(--color-primary);
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-ms-confirm:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.btn-ms-confirm:disabled { opacity: 0.5; cursor: default; }
.btn-ms-cancel {
padding: 0.3rem 0.65rem;
background: none;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
background: var(--color-action-secondary);
border: none;
color: #fff;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
transition: background 0.15s;
}
.btn-ms-cancel:hover { background: var(--color-action-secondary-hover); }
/* ── Milestone group ─────────────────────────────────────────── */
.milestone-group {
@@ -996,7 +1018,7 @@ async function confirmDelete() {
.milestone-header.clickable:hover { background: color-mix(in srgb, var(--color-primary) 4%, var(--color-bg-secondary)); }
.ms-chevron { display: flex; align-items: center; color: var(--color-text-muted); flex-shrink: 0; }
.ms-name { font-weight: 600; color: var(--color-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-name { font-weight: 500; color: var(--color-text); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-count {
font-size: 0.7rem;
color: var(--color-text-muted);
@@ -1005,7 +1027,7 @@ async function confirmDelete() {
border-radius: 999px;
padding: 0.05rem 0.4rem;
flex-shrink: 0;
font-weight: 600;
font-weight: 500;
}
.ms-progress-track {
width: 72px;
@@ -1021,7 +1043,7 @@ async function confirmDelete() {
border-radius: 999px;
transition: width 0.4s ease;
}
.ms-pct { font-size: 0.72rem; color: var(--color-text-secondary); flex-shrink: 0; min-width: 2.4rem; text-align: right; font-weight: 600; }
.ms-pct { font-size: 0.72rem; color: var(--color-text-secondary); flex-shrink: 0; min-width: 2.4rem; text-align: right; font-weight: 500; }
.ms-actions { display: flex; gap: 0.15rem; margin-left: 0.2rem; opacity: 0; transition: opacity 0.15s; }
.milestone-header:hover .ms-actions { opacity: 1; }
@@ -1050,7 +1072,7 @@ async function confirmDelete() {
color: var(--color-text);
font-size: 0.85rem;
font-family: inherit;
font-weight: 600;
font-weight: 500;
outline: none;
}
@@ -1081,7 +1103,7 @@ async function confirmDelete() {
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
@@ -1096,7 +1118,7 @@ async function confirmDelete() {
padding: 0.05rem 0.4rem;
font-size: 0.68rem;
color: var(--color-text-muted);
font-weight: 600;
font-weight: 500;
}
.col-add-btn {
display: inline-flex;
@@ -1242,8 +1264,8 @@ async function confirmDelete() {
font-family: inherit;
}
.modal-btn:hover { background: var(--color-bg); }
.modal-btn-danger { background: var(--color-danger, #e74c3c); border-color: var(--color-danger, #e74c3c); color: #fff; }
.modal-btn-danger:hover { opacity: 0.9; }
.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); }
/* ── Skeleton ────────────────────────────────────────────────── */
@keyframes skel-shine { to { background-position: 200% center; } }
+329 -220
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { X } from "lucide-vue-next";
import { ref, computed, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile } from "@/api/client";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, getJournalConfig, saveJournalConfig, geocodeAddress, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile, type JournalConfig } from "@/api/client";
import { usePushStore } from "@/stores/push";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
@@ -593,6 +594,93 @@ async function runConsolidate() {
function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) }
// ── Journal config (locations, temp unit, prep schedule) ────────────────────
const journalConfig = ref<JournalConfig>({
prep_enabled: true,
prep_hour: 5,
prep_minute: 0,
day_rollover_hour: 4,
locations: { home: { label: 'Home', address: '' }, work: { label: 'Work', address: '' } },
temp_unit: 'C',
})
const journalConfigSaving = ref(false)
const journalConfigSaved = ref(false)
const homeQuery = ref('')
const workQuery = ref('')
const homeGeoStatus = ref<'' | 'ok' | 'error' | 'pending'>('')
const workGeoStatus = ref<'' | 'ok' | 'error' | 'pending'>('')
const homeGeoMsg = ref('')
const workGeoMsg = ref('')
async function loadJournalConfig() {
try {
const cfg = await getJournalConfig()
journalConfig.value = {
prep_enabled: cfg.prep_enabled ?? true,
prep_hour: cfg.prep_hour ?? 5,
prep_minute: cfg.prep_minute ?? 0,
day_rollover_hour: cfg.day_rollover_hour ?? 4,
morning_end_hour: cfg.morning_end_hour,
midday_end_hour: cfg.midday_end_hour,
locations: {
home: cfg.locations?.home ?? { label: 'Home', address: '' },
work: cfg.locations?.work ?? { label: 'Work', address: '' },
},
temp_unit: cfg.temp_unit ?? 'C',
}
homeQuery.value = cfg.locations?.home?.address ?? ''
workQuery.value = cfg.locations?.work?.address ?? ''
} catch { /* non-critical */ }
}
async function geocodeFor(which: 'home' | 'work') {
const query = (which === 'home' ? homeQuery.value : workQuery.value).trim()
const statusRef = which === 'home' ? homeGeoStatus : workGeoStatus
const msgRef = which === 'home' ? homeGeoMsg : workGeoMsg
const label = which === 'home' ? 'Home' : 'Work'
if (!query) {
// Clear location
if (!journalConfig.value.locations) journalConfig.value.locations = {}
journalConfig.value.locations[which] = { label, address: '' }
statusRef.value = ''
msgRef.value = ''
return
}
statusRef.value = 'pending'
msgRef.value = 'Looking up…'
try {
const result = await geocodeAddress(query)
if (!result) {
statusRef.value = 'error'
msgRef.value = `No match for "${query}"`
return
}
if (!journalConfig.value.locations) journalConfig.value.locations = {}
journalConfig.value.locations[which] = {
label,
address: query,
lat: result.lat,
lon: result.lon,
}
statusRef.value = 'ok'
msgRef.value = `Found: ${result.display_name}`
} catch {
statusRef.value = 'error'
msgRef.value = 'Geocoding failed'
}
}
async function saveJournalCfg() {
journalConfigSaving.value = true
journalConfigSaved.value = false
try {
await saveJournalConfig(journalConfig.value)
journalConfigSaved.value = true
setTimeout(() => { journalConfigSaved.value = false }, 2000)
} catch { toastStore.show('Failed to save journal settings', 'error') }
finally { journalConfigSaving.value = false }
}
async function clearObservations() {
if (!confirm('Clear all learned observations and the generated summary? This cannot be undone.')) return
clearingObs.value = true
@@ -644,6 +732,9 @@ onMounted(async () => {
// Load user profile
await loadProfile();
// Load journal config (locations, temp unit, prep schedule)
await loadJournalConfig();
// Load voice settings
if (allSettings.voice_tts_voice) voiceTtsVoice.value = allSettings.voice_tts_voice;
if (allSettings.voice_tts_speed) voiceTtsSpeed.value = parseFloat(allSettings.voice_tts_speed);
@@ -1387,7 +1478,7 @@ function formatUserDate(iso: string): string {
<!-- Timezone -->
<section class="settings-section full-width">
<h2>Timezone</h2>
<p class="section-desc">Used to schedule briefings and format times in chat. Set this to your local IANA timezone (e.g. America/New_York, Europe/London).</p>
<p class="section-desc">Used to schedule the daily journal prep and format times in chat. Set this to your local IANA timezone (e.g. America/New_York, Europe/London).</p>
<div class="field">
<label for="user-timezone">Your timezone</label>
<div style="display:flex; gap:0.5rem; align-items:center">
@@ -1591,7 +1682,7 @@ function formatUserDate(iso: string): string {
<div v-show="activeTab === 'profile'" class="settings-grid">
<section class="settings-section full-width">
<h2>About You</h2>
<p class="section-desc">This information is used by the assistant to personalise responses in chat and briefings.</p>
<p class="section-desc">This information is used by the assistant to personalise responses in chat and the daily journal.</p>
<div class="assistant-grid">
<div class="field">
<label>Display Name</label>
@@ -1650,7 +1741,7 @@ function formatUserDate(iso: string): string {
<section class="settings-section full-width">
<h2>Interests</h2>
<p class="section-desc">Topics you care about — used to personalise news and briefing context.</p>
<p class="section-desc">Topics you care about — used to personalise the journal's daily prep and chat responses.</p>
<TagInput v-model="profile.interests" placeholder="Add an interest…" :fetchTags="emptyTagsFetch" />
<div class="actions">
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
@@ -1660,7 +1751,7 @@ function formatUserDate(iso: string): string {
<section class="settings-section full-width">
<h2>Work Schedule</h2>
<p class="section-desc">Helps the briefing understand when you're working and what's relevant each morning.</p>
<p class="section-desc">Helps the journal understand when you're working and what's relevant each morning.</p>
<div class="field">
<label>Work Days</label>
<div class="day-picker">
@@ -1690,14 +1781,128 @@ function formatUserDate(iso: string): string {
</div>
</section>
<section class="settings-section full-width">
<h2>Locations</h2>
<p class="section-desc">Home and work locations are used by the journal's daily prep to fetch local weather. Place names are geocoded once on save.</p>
<div class="field">
<label>Home</label>
<div class="location-row">
<input
v-model="homeQuery"
type="text"
class="input"
placeholder="e.g. Brooklyn, NY"
@blur="geocodeFor('home')"
@keydown.enter.prevent="geocodeFor('home')"
/>
</div>
<p v-if="homeGeoStatus === 'ok'" class="geo-msg geo-ok">{{ homeGeoMsg }}</p>
<p v-else-if="homeGeoStatus === 'error'" class="geo-msg geo-error">{{ homeGeoMsg }}</p>
<p v-else-if="homeGeoStatus === 'pending'" class="geo-msg geo-pending">{{ homeGeoMsg }}</p>
</div>
<div class="field">
<label>Work</label>
<div class="location-row">
<input
v-model="workQuery"
type="text"
class="input"
placeholder="e.g. Manhattan, NY"
@blur="geocodeFor('work')"
@keydown.enter.prevent="geocodeFor('work')"
/>
</div>
<p v-if="workGeoStatus === 'ok'" class="geo-msg geo-ok">{{ workGeoMsg }}</p>
<p v-else-if="workGeoStatus === 'error'" class="geo-msg geo-error">{{ workGeoMsg }}</p>
<p v-else-if="workGeoStatus === 'pending'" class="geo-msg geo-pending">{{ workGeoMsg }}</p>
</div>
<div class="field">
<label>Temperature unit</label>
<div class="unit-toggle">
<button
type="button"
class="unit-btn"
:class="{ active: journalConfig.temp_unit === 'C' }"
@click="journalConfig.temp_unit = 'C'"
>Celsius</button>
<button
type="button"
class="unit-btn"
:class="{ active: journalConfig.temp_unit === 'F' }"
@click="journalConfig.temp_unit = 'F'"
>Fahrenheit</button>
</div>
</div>
<div class="actions">
<button class="btn-save" @click="saveJournalCfg" :disabled="journalConfigSaving">{{ journalConfigSaving ? 'Saving' : 'Save' }}</button>
<span v-if="journalConfigSaved" class="saved-msg">Saved!</span>
</div>
</section>
<section class="settings-section full-width">
<h2>Journal</h2>
<p class="section-desc">Controls when the daily journal prep generates and how the day is framed.</p>
<div class="field">
<label class="checkbox-label">
<input type="checkbox" v-model="journalConfig.prep_enabled" />
Generate daily prep automatically
</label>
<p class="field-hint">When enabled, the journal generates a morning briefing at the time below. When off, the journal still works — just without the auto-generated daily prep.</p>
</div>
<div class="assistant-grid">
<div class="field">
<label>Prep generates at</label>
<div class="time-row">
<input
type="number"
min="0"
max="23"
v-model.number="journalConfig.prep_hour"
class="input time-input"
:disabled="!journalConfig.prep_enabled"
/>
<span class="time-sep">:</span>
<input
type="number"
min="0"
max="59"
step="5"
v-model.number="journalConfig.prep_minute"
class="input time-input"
:disabled="!journalConfig.prep_enabled"
/>
</div>
<p class="field-hint">24-hour. Generates each morning at this local time.</p>
</div>
<div class="field">
<label>Day rolls over at</label>
<div class="time-row">
<input
type="number"
min="0"
max="23"
v-model.number="journalConfig.day_rollover_hour"
class="input time-input"
/>
<span class="time-sep time-sep--quiet">:00</span>
</div>
<p class="field-hint">Hour when the journal switches to a new day. Default 4am — late-night entries (13am) still count as the previous day.</p>
</div>
</div>
<div class="actions">
<button class="btn-save" @click="saveJournalCfg" :disabled="journalConfigSaving">{{ journalConfigSaving ? 'Saving' : 'Save' }}</button>
<span v-if="journalConfigSaved" class="saved-msg">Saved!</span>
</div>
</section>
<section class="settings-section full-width">
<h2>What the Assistant Has Learned</h2>
<p class="section-desc">
The assistant observes patterns from your briefing conversations and builds a summary over time.
The assistant observes patterns from your journal and chat conversations and builds a summary over time. The summary is included in the journal's system prompt so the daily prep can reference what it knows about you.
<span v-if="profile.observations_count > 0"> {{ profile.observations_count }} raw observation{{ profile.observations_count !== 1 ? 's' : '' }} stored.</span>
</p>
<div v-if="profile.learned_summary" class="learned-summary">{{ profile.learned_summary }}</div>
<div v-else class="learned-empty">No learned summary yet. Observations accumulate from daily briefing conversations.</div>
<div v-else class="learned-empty">No learned summary yet. Observations accumulate from journal and chat conversations.</div>
<div class="actions" style="gap:0.5rem;flex-wrap:wrap">
<button class="btn-secondary" @click="runConsolidate" :disabled="consolidating || profile.observations_count === 0">
{{ consolidating ? 'Consolidating…' : 'Consolidate Now' }}
@@ -2098,7 +2303,7 @@ function formatUserDate(iso: string): string {
class="btn-remove-slot"
@click="removeBlendSlot(idx)"
title="Remove this voice"
></button>
><X :size="16" /></button>
</div>
</div>
</div>
@@ -2770,7 +2975,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
}
.sidebar-group-label {
font-size: 0.65rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-text-muted);
@@ -2797,9 +3002,9 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
}
.sidebar-item.active {
color: var(--color-primary);
background: rgba(124, 58, 237, 0.08);
background: rgba(91, 74, 138, 0.08);
border-left-color: var(--color-primary);
font-weight: 600;
font-weight: 500;
}
/* Content panel */
@@ -2827,7 +3032,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
.settings-section h2 {
margin: 0 0 0.75rem;
font-size: 0.7rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-text-muted);
@@ -2856,7 +3061,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
.model-row-info { display: flex; align-items: center; gap: 0.4rem; min-width: 0; flex: 1; }
.model-name { font-size: 0.88rem; font-weight: 500; font-family: monospace; }
.model-badge {
font-size: 0.68rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-weight: 600; white-space: nowrap;
font-size: 0.68rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-weight: 500; white-space: nowrap;
}
.model-badge--loaded { background: color-mix(in srgb, #22c55e 18%, transparent); color: #22c55e; }
.model-badge--default { background: color-mix(in srgb, var(--color-primary) 18%, transparent); color: var(--color-primary); }
@@ -2867,7 +3072,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
font-size: 0.8rem; padding: 0.2rem 0.35rem; border-radius: 3px; line-height: 1;
transition: color 0.15s, background 0.15s;
}
.model-delete-btn:hover:not(:disabled) { color: var(--color-danger, #ef4444); background: color-mix(in srgb, var(--color-danger, #ef4444) 10%, transparent); }
.model-delete-btn:hover:not(:disabled) { color: var(--color-action-destructive); background: color-mix(in srgb, var(--color-action-destructive) 10%, transparent); }
.model-delete-btn:disabled { opacity: 0.4; cursor: default; }
.model-pull-form { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
.model-pull-form .input { flex: 1; }
@@ -2921,7 +3126,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
.field label {
display: block;
font-size: 0.875rem;
font-weight: 600;
font-weight: 500;
margin-bottom: 0.35rem;
color: var(--color-text);
}
@@ -2956,9 +3161,10 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
gap: 0.65rem;
flex-wrap: wrap;
}
/* Save: Moss action-primary per Hybrid */
.btn-save {
padding: 0.4rem 0.9rem;
background: var(--color-primary);
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
@@ -2966,52 +3172,72 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.btn-save:disabled { opacity: 0.6; cursor: default; }
.btn-save:hover:not(:disabled) { opacity: 0.9; }
.btn-save:hover:not(:disabled) { background: var(--color-action-primary-hover); }
/* Danger outline (Invalidate sessions, Clear observations, etc.):
Oxblood action-destructive ghost — fills on hover */
.btn-danger-outline {
padding: 0.4rem 0.9rem;
background: none;
color: var(--color-danger, #e74c3c);
border: 1px solid var(--color-danger, #e74c3c);
color: var(--color-action-destructive);
border: 1px solid var(--color-action-destructive);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.btn-danger-outline:hover:not(:disabled) {
background: var(--color-danger, #e74c3c);
background: var(--color-action-destructive);
color: #fff;
}
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
.btn-secondary {
/* Filled destructive: Oxblood action-destructive */
.btn-danger {
padding: 0.4rem 0.9rem;
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
background: var(--color-action-destructive);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
.btn-danger:disabled { opacity: 0.5; cursor: default; }
/* Secondary: Bronze action-secondary — alternate paths (Detect, Test,
Refresh, Add slot, etc.). Outline form for visual lightness. */
.btn-secondary {
padding: 0.4rem 0.9rem;
background: var(--color-action-secondary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.btn-secondary:hover:not(:disabled) { background: var(--color-action-secondary-hover); }
.btn-secondary:disabled { opacity: 0.6; cursor: default; }
.btn-warn:hover:not(:disabled) {
border-color: var(--color-warning);
color: var(--color-warning);
background: var(--color-warning);
color: #fff;
}
.saved-msg {
color: var(--color-success);
font-size: 0.875rem;
font-weight: 600;
font-weight: 500;
}
.input-error { border-color: var(--color-danger); }
.input-error:focus { border-color: var(--color-danger); }
@@ -3099,7 +3325,6 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
border-radius: 4px;
}
.not-configured {
font-style: italic;
opacity: 0.75;
}
.search-row {
@@ -3137,7 +3362,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
}
.result-title {
font-size: 0.9rem;
font-weight: 600;
font-weight: 500;
color: var(--color-primary);
text-decoration: none;
word-break: break-word;
@@ -3179,7 +3404,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
.subsection-label {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text-secondary);
}
.test-email-section {
@@ -3197,7 +3422,6 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
.push-unsupported {
font-size: 0.875rem;
color: var(--color-text-muted);
font-style: italic;
}
.push-status-row {
display: flex;
@@ -3213,7 +3437,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
.push-permission-badge,
.push-sub-badge {
font-size: 0.72rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.1rem 0.4rem;
@@ -3302,14 +3526,14 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
.subsection-label {
margin: 0 0 0.5rem;
font-size: 0.85rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text-secondary);
}
.users-table { width: 100%; border-collapse: collapse; }
.users-table th {
text-align: left;
font-size: 0.8rem;
font-weight: 600;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
@@ -3322,14 +3546,14 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
font-size: 0.9rem;
}
.users-table tbody tr:last-child td { border-bottom: none; }
.cell-username { font-weight: 600; }
.cell-username { font-weight: 500; }
.cell-email { color: var(--color-text-secondary); }
.cell-date { color: var(--color-text-muted); font-size: 0.85rem; }
.cell-actions { white-space: nowrap; }
.role-badge {
display: inline-block;
font-size: 0.7rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.4rem;
@@ -3343,7 +3567,8 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
color: var(--color-text-muted);
background: var(--color-bg-secondary);
}
.you-label { font-size: 0.8rem; color: var(--color-text-muted); font-style: italic; }
.you-label { font-size: 0.8rem; color: var(--color-text-muted); }
/* Per-row delete (users / invitations / etc.): ghost → Oxblood on hover */
.btn-delete {
padding: 0.25rem 0.6rem;
background: transparent;
@@ -3353,20 +3578,22 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
cursor: pointer;
font-size: 0.8rem;
}
.btn-delete:hover:not(:disabled) { border-color: var(--color-danger); color: var(--color-danger); }
.btn-delete:hover:not(:disabled) { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
.btn-delete:disabled { opacity: 0.4; cursor: default; }
/* Two-stage destructive: Confirm = Oxblood filled, Cancel = Bronze ghost */
.btn-confirm-delete {
padding: 0.25rem 0.6rem;
background: var(--color-danger);
background: var(--color-action-destructive);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
font-weight: 500;
margin-right: 0.25rem;
transition: background 0.15s;
}
.btn-confirm-delete:hover:not(:disabled) { filter: brightness(0.9); }
.btn-confirm-delete:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
.btn-confirm-delete:disabled { opacity: 0.6; cursor: default; }
.btn-cancel-delete {
padding: 0.25rem 0.6rem;
@@ -3378,18 +3605,20 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
font-size: 0.8rem;
}
.btn-cancel-delete:hover { color: var(--color-text); border-color: var(--color-text-muted); }
/* Toggle (Open/Close registration, etc.): Open = Moss, Close = Pewter ghost */
.btn-toggle {
padding: 0.45rem 1rem;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
font-weight: 500;
white-space: nowrap;
transition: background 0.15s;
}
.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; }
.btn-toggle-open { background: var(--color-action-primary); color: #fff; }
.btn-toggle-open:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.btn-toggle-close {
background: var(--color-bg-secondary);
color: var(--color-text);
@@ -3414,10 +3643,10 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
align-items: center;
gap: 0.15rem;
}
.stat-count { font-size: 1.5rem; font-weight: 700; color: var(--color-text); }
.stat-count { font-size: 1.5rem; font-weight: 500; color: var(--color-text); }
.stat-label {
font-size: 0.75rem;
font-weight: 600;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
@@ -3435,7 +3664,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
.logs-table th {
text-align: left;
font-size: 0.8rem;
font-weight: 600;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
@@ -3467,7 +3696,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
}
.category-badge {
display: inline-block;
font-size: 0.65rem; font-weight: 700;
font-size: 0.65rem; font-weight: 500;
text-transform: uppercase; letter-spacing: 0.05em;
padding: 0.1rem 0.35rem; border-radius: var(--radius-sm);
}
@@ -3476,7 +3705,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
.cat-error { color: var(--color-danger); background: color-mix(in srgb, var(--color-danger) 15%, transparent); }
.method-tag {
display: inline-block;
font-size: 0.65rem; font-weight: 700; font-family: monospace;
font-size: 0.65rem; font-weight: 500; font-family: monospace;
padding: 0.05rem 0.25rem; border-radius: 3px;
background: var(--color-bg-secondary); color: var(--color-text-muted);
margin-right: 0.25rem;
@@ -3502,9 +3731,10 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
}
/* ── Groups tab ──────────────────────────────────────────────── */
/* Moss action-primary per Hybrid */
.btn-primary {
padding: 0.4rem 0.9rem;
background: var(--color-primary);
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
@@ -3512,9 +3742,10 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
transition: background 0.15s;
}
.btn-primary:disabled { opacity: 0.6; cursor: default; }
.btn-primary:hover:not(:disabled) { opacity: 0.9; }
.btn-primary:hover:not(:disabled) { background: var(--color-action-primary-hover); }
.input-field {
width: 100%;
@@ -3541,7 +3772,6 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
.loading-msg, .empty-msg {
color: var(--color-text-muted);
font-style: italic;
font-size: 0.88rem;
padding: 0.5rem 0;
}
@@ -3575,7 +3805,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
}
.group-name {
font-weight: 600;
font-weight: 500;
font-size: 0.9rem;
color: var(--color-text);
}
@@ -3610,7 +3840,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
transition: border-color 0.15s, color 0.15s;
}
.btn-sm:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-danger-sm:hover { border-color: var(--color-danger, #e74c3c); color: var(--color-danger, #e74c3c); }
.btn-danger-sm:hover { border-color: var(--color-action-destructive); color: var(--color-action-destructive); }
.group-members-panel {
padding: 0.75rem 1rem 1rem;
@@ -3656,7 +3886,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
transition: background 0.1s;
}
.member-result-item:hover { background: var(--color-hover); }
.member-result-name { font-weight: 600; font-size: 0.85rem; }
.member-result-name { font-weight: 500; font-size: 0.85rem; }
.member-result-email { color: var(--color-text-muted); font-size: 0.78rem; }
.role-select {
@@ -3692,7 +3922,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
.member-role-badge {
font-size: 0.7rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.4rem;
@@ -3703,199 +3933,79 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
.members-empty {
color: var(--color-text-muted);
font-style: italic;
font-size: 0.82rem;
padding: 0.25rem 0.5rem;
}
/* Briefing tab */
.briefing-location-row {
margin-bottom: 1rem;
}
.briefing-input-group {
/* Profile — Locations + Journal sections */
.location-row {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
.briefing-geo-confirmed {
.geo-msg {
font-size: 0.78rem;
color: var(--color-success, #22c55e);
margin-top: 0.2rem;
margin: 0.2rem 0 0;
}
.briefing-geo-error {
font-size: 0.78rem;
color: var(--color-danger, #ef4444);
margin-top: 0.2rem;
}
.briefing-day-toggles {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.briefing-unit-toggle {
.geo-ok { color: var(--color-success); }
.geo-error { color: var(--color-danger); }
.geo-pending { color: var(--color-text-muted); }
.unit-toggle {
display: flex;
gap: 0;
border: 1px solid var(--color-border);
border-radius: 8px;
border-radius: var(--radius-sm);
overflow: hidden;
width: fit-content;
margin-top: 0.25rem;
}
.briefing-unit-btn {
padding: 0.35rem 1rem;
.unit-btn {
padding: 0.4rem 1rem;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
border: none;
font-family: inherit;
transition: all 0.15s;
transition: background 0.15s, color 0.15s;
}
.briefing-unit-btn:first-child {
.unit-btn:not(:last-child) {
border-right: 1px solid var(--color-border);
}
.briefing-unit-btn.active {
background: var(--color-primary);
.unit-btn.active {
background: var(--color-action-primary);
color: #fff;
}
.briefing-day-btn {
padding: 0.35rem 0.65rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.82rem;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
.unit-btn:hover:not(.active) {
color: var(--color-text);
}
.briefing-day-btn.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.briefing-slot-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: 0.5rem;
}
.briefing-slot-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.45rem 0.75rem;
border-radius: 8px;
background: var(--color-bg-secondary);
}
.briefing-slot-info {
display: flex;
align-items: center;
gap: 0.75rem;
}
.briefing-slot-label {
font-size: 0.88rem;
font-weight: 500;
}
.briefing-slot-time {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.briefing-feeds-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin-bottom: 0.75rem;
}
.briefing-feed-row {
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
border-radius: 6px;
background: var(--color-bg-secondary);
}
.briefing-feed-info {
flex: 1;
min-width: 0;
}
.briefing-feed-title {
font-size: 0.85rem;
font-weight: 500;
display: block;
}
.briefing-feed-url {
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.briefing-btn-remove {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.85rem;
padding: 0.2rem 0.4rem;
border-radius: 4px;
flex-shrink: 0;
transition: color 0.15s;
font-size: 0.9rem;
}
.briefing-btn-remove:hover { color: var(--color-danger, #ef4444); }
.briefing-feeds-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
.checkbox-label input[type="checkbox"] {
cursor: pointer;
}
.briefing-feeds-header h2 { margin: 0; }
.briefing-feeds-header .section-desc { margin: 0.25rem 0 0; }
.briefing-refresh-btn { white-space: nowrap; flex-shrink: 0; }
.briefing-feed-title-row {
.time-row {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.briefing-feed-cat {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: 3px;
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
font-weight: 500;
.time-input {
width: 4rem;
text-align: center;
}
.briefing-feed-age {
font-size: 0.72rem;
.time-sep {
font-size: 1.1rem;
color: var(--color-text-muted);
display: block;
margin-top: 0.1rem;
}
.briefing-add-feed-form {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: flex-end;
margin-top: 0.75rem;
}
.briefing-add-feed-inputs {
display: flex;
gap: 0.5rem;
flex: 1;
flex-wrap: wrap;
}
.briefing-add-feed-inputs .input { flex: 1; min-width: 160px; }
.briefing-cat-input { max-width: 180px; }
.briefing-add-feed {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.time-sep--quiet { font-size: 0.9rem; }
/* API Keys tab */
.api-key-create-form {
@@ -3947,13 +4057,13 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
}
.api-keys-table th { font-weight: 600; opacity: 0.7; }
.api-keys-table th { font-weight: 500; opacity: 0.7; }
.scope-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 9999px;
font-size: 0.78rem;
font-weight: 600;
font-weight: 500;
}
.scope-badge.read { background: color-mix(in srgb, #3b82f6 15%, transparent); color: #3b82f6; }
.scope-badge.write { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; }
@@ -3974,7 +4084,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
border-radius: 8px;
}
.mcp-pkg-name { font-family: monospace; font-size: 0.9rem; flex: 1; }
.mcp-install-steps h3 { font-size: 0.95rem; font-weight: 600; margin-bottom: 0.75rem; }
.mcp-install-steps h3 { font-size: 0.95rem; font-weight: 500; margin-bottom: 0.75rem; }
.mcp-install-steps ol { padding-left: 1.25rem; display: flex; flex-direction: column; gap: 0.75rem; }
.mcp-install-steps li { line-height: 1.6; font-size: 0.9rem; }
.mcp-code {
@@ -4011,7 +4121,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
.mcp-client-tab.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 600;
font-weight: 500;
}
.mcp-code-row {
display: flex;
@@ -4048,7 +4158,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
}
.status-badge {
font-size: 0.75rem;
font-weight: 600;
font-weight: 500;
padding: 0.15rem 0.55rem;
border-radius: 999px;
}
@@ -4195,7 +4305,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
font-weight: 600;
font-weight: 500;
}
.learned-summary {
background: var(--color-bg-secondary);
@@ -4212,22 +4322,21 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
.learned-empty {
font-size: 0.85rem;
color: var(--color-text-muted);
font-style: italic;
margin-bottom: 0.75rem;
}
.btn-danger-outline {
padding: 0.45rem 1rem;
background: none;
border: 1px solid var(--color-danger, #e74c3c);
border: 1px solid var(--color-action-destructive);
border-radius: var(--radius-sm);
color: var(--color-danger, #e74c3c);
color: var(--color-action-destructive);
font-size: 0.9rem;
cursor: pointer;
font-family: inherit;
transition: background 0.15s, color 0.15s;
}
.btn-danger-outline:hover:not(:disabled) {
background: var(--color-danger, #e74c3c);
background: var(--color-action-destructive);
color: #fff;
}
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
-1
View File
@@ -248,7 +248,6 @@ onMounted(async () => {
.empty-msg {
color: var(--color-muted);
font-style: italic;
font-size: 0.88rem;
margin: 0;
padding: 1rem 0;
+15 -6
View File
@@ -25,6 +25,7 @@ import DiffView from "@/components/DiffView.vue";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
import VersionHistorySection from "@/components/VersionHistorySection.vue";
import RecurrenceEditor from "@/components/RecurrenceEditor.vue";
import { Trash2 } from "lucide-vue-next";
const route = useRoute();
const router = useRouter();
@@ -414,7 +415,9 @@ useEditorGuards(dirty, save);
<button class="btn-save" @click="save" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<button v-if="isEditing" class="btn-delete" @click="remove">Delete</button>
<button v-if="isEditing" class="btn-delete" @click="remove">
<Trash2 :size="16" /> Delete
</button>
<WordCount :body="body" />
</div>
<input
@@ -737,12 +740,19 @@ useEditorGuards(dirty, save);
border-bottom: 1px solid var(--color-border);
}
/* .task-main is a flex column; without flex-shrink: 0, long body content
gets squeezed back to min-height and overflows visibly on top of siblings. */
.body-editor-wrap,
.body-log {
flex-shrink: 0;
}
.body-editor-wrap {
min-height: 200px;
}
.body-log {
/* TaskLogSection already has its own border/bg */
:deep(.preview-pane) {
flex-shrink: 0;
}
/* Right sidebar: metadata fields */
@@ -811,7 +821,7 @@ useEditorGuards(dirty, save);
}
.subtasks-label {
font-size: 0.75rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
@@ -889,7 +899,6 @@ useEditorGuards(dirty, save);
.stream-label {
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
}
.stream-preview {
border: 1px solid var(--color-input-border);
@@ -911,7 +920,7 @@ useEditorGuards(dirty, save);
}
.assist-section-title {
font-size: 0.78rem;
font-weight: 700;
font-weight: 500;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
+31 -44
View File
@@ -13,6 +13,7 @@ import PriorityBadge from "@/components/PriorityBadge.vue";
import TagPill from "@/components/TagPill.vue";
import TableOfContents from "@/components/TableOfContents.vue";
import ShareDialog from "@/components/ShareDialog.vue";
import { Clock, Pencil, Link as LinkIcon } from "lucide-vue-next";
const route = useRoute();
const router = useRouter();
@@ -314,12 +315,12 @@ const subTaskProgress = computed(() => {
<h1 class="task-title">{{ store.currentTask.title || "Untitled" }}</h1>
<p class="meta">
<span class="meta-item">
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67V7z"/></svg>
<Clock :size="16" />
Updated {{ relativeTime(store.currentTask.updated_at) }}
</span>
<span class="meta-sep" aria-hidden="true">·</span>
<span class="meta-item">
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor" aria-hidden="true"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
<Pencil :size="16" />
Created {{ relativeTime(store.currentTask.created_at) }}
</span>
</p>
@@ -395,7 +396,7 @@ const subTaskProgress = computed(() => {
<div v-if="backlinks.length" class="backlinks">
<h3 class="backlinks-heading">
<svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
<LinkIcon :size="16" />
Backlinks
<span class="backlinks-count">{{ backlinks.length }}</span>
</h3>
@@ -474,55 +475,41 @@ const subTaskProgress = computed(() => {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-edit {
/* 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(--gradient-cta);
background: var(--color-action-primary);
color: #fff;
text-decoration: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
box-shadow: var(--glow-cta);
transition: box-shadow 0.15s, opacity 0.15s;
font-weight: 500;
transition: background 0.15s;
}
.btn-edit:hover {
box-shadow: var(--glow-cta-hover);
opacity: 0.95;
.btn-edit:hover,
.btn-advance:hover {
background: var(--color-action-primary-hover);
color: #fff;
}
.btn-advance {
padding: 0.45rem 1rem;
background: var(--gradient-cta);
/* 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.9rem;
font-weight: 600;
transition: opacity 0.15s;
}
.btn-advance:hover {
opacity: 0.88;
}
.btn-convert {
margin-left: auto;
padding: 0.3rem 0.75rem;
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.85rem;
transition: background 0.15s;
}
.btn-convert:hover {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.btn-convert:hover { background: var(--color-action-secondary-hover); }
.btn-convert:disabled {
opacity: 0.6;
cursor: default;
@@ -530,21 +517,21 @@ const subTaskProgress = computed(() => {
.btn-share {
padding: 0.3rem 0.75rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
background: var(--color-action-secondary);
border: none;
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
color: #fff;
cursor: pointer;
font-size: 0.85rem;
font-family: inherit;
transition: border-color 0.15s, color 0.15s;
transition: background 0.15s;
}
.btn-share:hover { border-color: var(--color-primary); color: var(--color-primary); }
.btn-share:hover { background: var(--color-action-secondary-hover); }
.task-title {
font-family: "Fraunces", Georgia, serif;
font-size: 2rem;
font-weight: 700;
font-weight: 500;
line-height: 1.2;
margin: 0.25rem 0 0.5rem;
color: var(--color-text);
@@ -579,7 +566,7 @@ const subTaskProgress = computed(() => {
}
.due-date.overdue {
color: var(--color-overdue);
font-weight: 600;
font-weight: 500;
}
.task-meta-row {
display: flex;
@@ -617,7 +604,7 @@ const subTaskProgress = computed(() => {
.subtasks-title {
font-size: 1rem;
margin: 0;
font-weight: 600;
font-weight: 500;
}
.subtasks-progress {
font-size: 0.8rem;
@@ -717,7 +704,7 @@ const subTaskProgress = computed(() => {
align-items: center;
gap: 0.4rem;
font-size: 0.78rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
@@ -759,7 +746,7 @@ const subTaskProgress = computed(() => {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
font-weight: 500;
padding: 0.1rem 0.45rem;
border-radius: 999px;
flex-shrink: 0;
@@ -477,7 +477,6 @@ function formatDate(iso: string): string {
.you-label {
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
}
.btn-delete {
padding: 0.25rem 0.6rem;
-1
View File
@@ -183,7 +183,6 @@ onMounted(async () => {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-style: italic;
}
.ws-panel-toggles {
+39
View File
@@ -8,6 +8,7 @@ The ambient endpoints read locations + temp_unit + topic preferences from the
"""
from __future__ import annotations
import asyncio
import datetime
import json
import logging
@@ -222,12 +223,50 @@ async def _journal_temp_unit(user_id: int) -> str:
# ── Weather ───────────────────────────────────────────────────────────────────
_STALE_THRESHOLD_SECONDS = 4 * 3600 # 4 hours — start refreshing well before the 7-day forecast window slides past today
def _is_stale(cache_row) -> bool:
if cache_row is None or cache_row.fetched_at is None:
return True
age = (datetime.datetime.now(datetime.timezone.utc) - cache_row.fetched_at).total_seconds()
return age > _STALE_THRESHOLD_SECONDS
async def _refresh_stale_in_background(user_id: int, stale_keys: set[str]) -> None:
"""Best-effort refresh of stale cache rows. Silently no-ops if the user's
config has no usable lat/lon for a given location_key."""
cfg = await _resolve_config(user_id)
locations = cfg.get("locations") or {}
for key in stale_keys:
loc = locations.get(key)
if not loc or not loc.get("lat") or not loc.get("lon"):
continue
try:
await weather_svc.refresh_location_cache(
user_id=user_id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
except Exception:
logger.warning("Background weather refresh failed for user %d / %s", user_id, key, exc_info=True)
@journal_bp.get("/weather")
@login_required
async def get_weather():
user_id = get_current_user_id()
rows = await weather_svc.get_cached_weather_rows(user_id)
temp_unit = await _journal_temp_unit(user_id)
# Kick off a best-effort background refresh for stale rows so the next page
# load gets fresh data; we still serve whatever's currently cached now.
stale_keys = {row.location_key for row in rows if _is_stale(row)}
if stale_keys:
asyncio.create_task(_refresh_stale_in_background(user_id, stale_keys))
cards = [
card for row in rows
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
+4 -1
View File
@@ -199,7 +199,10 @@ async def get_project_summary(user_id: int, project_id: int) -> dict:
)
.group_by(Note.status)
)
task_counts: dict[str, int] = {}
# Initialise all three lifecycle keys to 0 so consumers can sum them
# safely without `?? 0` guards. Frontend interface declares all three
# as required; rendering `undefined + N` yields NaN.
task_counts: dict[str, int] = {"todo": 0, "in_progress": 0, "done": 0}
for status, count in task_rows.fetchall():
task_counts[status] = count
+4 -6
View File
@@ -156,15 +156,13 @@ def parse_weather_card_data(
) -> dict | None:
"""
Parse a WeatherCache row into the metadata.weather card schema.
Returns None if the cache is stale (older than 24 hours) or unavailable.
Returns None if the cache row is missing or unparseable. Stale data is
returned as-is the frontend uses `fetched_at` to convey freshness, and
WeatherCard handles missing today/forecast fields gracefully.
"""
from datetime import date, timedelta
if cache_row is None or cache_row.fetched_at is None:
return None
age_seconds = (datetime.now(timezone.utc) - cache_row.fetched_at).total_seconds()
if age_seconds > 86400:
if cache_row is None or cache_row.fetched_at is None or not cache_row.forecast_json:
return None
raw = cache_row.forecast_json or {}