Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1236220302 | |||
| e7d1b53ebf | |||
| ff19a1b4d3 | |||
| d9802df2af | |||
| c9b9e68c18 | |||
| 3af62a9cc5 | |||
| 221ae94f4f | |||
| 786bc92768 | |||
| ca3f845c34 | |||
| 617d81e7d4 |
@@ -413,6 +413,7 @@ jobs:
|
|||||||
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
|
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
|
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "BUNDLED_PACKAGES=*" >> "$GITHUB_OUTPUT"
|
||||||
echo "env_label=dev (PR #${{ gitea.event.pull_request.number }})" >> "$GITHUB_OUTPUT"
|
echo "env_label=dev (PR #${{ gitea.event.pull_request.number }})" >> "$GITHUB_OUTPUT"
|
||||||
elif [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then
|
elif [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then
|
||||||
VERSION="${{ gitea.ref_name }}"
|
VERSION="${{ gitea.ref_name }}"
|
||||||
@@ -429,6 +430,7 @@ jobs:
|
|||||||
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_REQUEST=100m" >> "$GITHUB_OUTPUT"
|
echo "CPU_REQUEST=100m" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_LIMIT=500m" >> "$GITHUB_OUTPUT"
|
echo "CPU_LIMIT=500m" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules" >> "$GITHUB_OUTPUT"
|
||||||
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
echo "is_release=true" >> "$GITHUB_OUTPUT"
|
||||||
echo "env_label=production (${VERSION})" >> "$GITHUB_OUTPUT"
|
echo "env_label=production (${VERSION})" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
@@ -444,6 +446,7 @@ jobs:
|
|||||||
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
|
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
|
||||||
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
|
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "BUNDLED_PACKAGES=notes,chat,chat-core" >> "$GITHUB_OUTPUT"
|
||||||
echo "env_label=test (main)" >> "$GITHUB_OUTPUT"
|
echo "env_label=test (main)" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -670,6 +673,7 @@ jobs:
|
|||||||
STORAGE_CLASS: ${{ vars.STORAGE_CLASS }}
|
STORAGE_CLASS: ${{ vars.STORAGE_CLASS }}
|
||||||
STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }}
|
STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }}
|
||||||
STORAGE_BACKEND: ${{ vars.STORAGE_BACKEND || 'pvc' }}
|
STORAGE_BACKEND: ${{ vars.STORAGE_BACKEND || 'pvc' }}
|
||||||
|
BUNDLED_PACKAGES: ${{ steps.setup.outputs.BUNDLED_PACKAGES }}
|
||||||
run: |
|
run: |
|
||||||
# Render PVC first (must exist before backend references it)
|
# Render PVC first (must exist before backend references it)
|
||||||
if [[ -n "${STORAGE_CLASS}" ]]; then
|
if [[ -n "${STORAGE_CLASS}" ]]; then
|
||||||
|
|||||||
375
CHANGELOG.md
375
CHANGELOG.md
@@ -2,6 +2,381 @@
|
|||||||
|
|
||||||
All notable changes to Armature are documented here.
|
All notable changes to Armature are documented here.
|
||||||
|
|
||||||
|
## v0.6.18 — CI Bundle Wiring
|
||||||
|
|
||||||
|
Wire `BUNDLED_PACKAGES` env var into the Gitea CI pipeline so each
|
||||||
|
environment gets the correct package set at boot.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **CI: dev deploys** — `BUNDLED_PACKAGES=*` (install all, matches docker-compose default).
|
||||||
|
- **CI: test deploys** — `BUNDLED_PACKAGES=notes,chat,chat-core` (core surfaces only).
|
||||||
|
- **CI: prod deploys** — `BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules`.
|
||||||
|
- **docker-compose.yml** — default `BUNDLED_PACKAGES` changed from empty to `*` (install all for local dev).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **TestBundledInstall_DefaultAllowlist** — updated test assertions to match v0.6.17's empty default set (was still expecting `notes` in curated defaults).
|
||||||
|
|
||||||
|
## v0.6.17 — Bug Fixes & Welcome Logic
|
||||||
|
|
||||||
|
Fixes broken UI interactions (folder creation, team member add), dropdown
|
||||||
|
overflow, welcome surface auto-disable, and bare-install default behavior.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Notes "Add folder" button** — `prompt()` replaced with `sw.prompt()`
|
||||||
|
so the dialog renders correctly in the extension iframe sandbox.
|
||||||
|
- **Admin "Add team members"** — user list API returns `{data:[…]}`; handler
|
||||||
|
now unwraps the envelope (`Array.isArray(u) ? u : u.data`) so the user
|
||||||
|
picker populates.
|
||||||
|
- **Package filter dropdown overflow** — removed `right:0` constraint on
|
||||||
|
`.sw-dropdown__list`, added `min-width:max-content` and `overflow-x:hidden`
|
||||||
|
so option labels ("Extension", "Workflow") render fully without a scrollbar.
|
||||||
|
- **Admin actions cell wrapping** — switched `.admin-actions-cell` from
|
||||||
|
`white-space:nowrap` to flexbox with `flex-wrap:wrap; gap:4px` so buttons
|
||||||
|
don't overflow on narrow viewports.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Welcome surface auto-disable** — welcome page now redirects to `/` when
|
||||||
|
any non-core extension surface is installed. Removed from the topbar
|
||||||
|
navigation surface list so it never appears alongside real surfaces.
|
||||||
|
- **Zero default bundled packages** — `defaultBundledPackages` map is now
|
||||||
|
empty. Fresh installs start bare; use `BUNDLED_PACKAGES` env var to control
|
||||||
|
what gets auto-installed per environment (`*` for all, comma-separated list
|
||||||
|
for selective).
|
||||||
|
- **Bundled filter logic** — `nil` (from `*`) means install all; empty map
|
||||||
|
(default) means install nothing. Previous code treated both as "install all".
|
||||||
|
- **User menu conditional items** — Docs, Settings, and Team Admin menu
|
||||||
|
entries only appear when those surfaces are actually enabled, not assumed.
|
||||||
|
- **SDK imperative host mount** — `ToastContainer` and `DialogStack` are now
|
||||||
|
auto-mounted by the SDK boot sequence for extension surfaces that lack an
|
||||||
|
AppShell, preventing missing toast/dialog hosts.
|
||||||
|
|
||||||
|
## v0.6.16 — Usability Survey Gate
|
||||||
|
|
||||||
|
Machine-auditable UI quality gate. Four new audit scripts, a structured survey
|
||||||
|
prompt, contrast and touch-target fixes, and Docker Hub documentation correction.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`scripts/generate-ui-inventory.sh`** — walks all kernel + package CSS,
|
||||||
|
extracts every class selector with surface, line number, responsive breakpoints,
|
||||||
|
spacing tokens, and font-size usage. Outputs `ui-inventory.json` (1567 entries).
|
||||||
|
- **`scripts/check-contrast.sh`** — parses `variables.css` dark/light token
|
||||||
|
pairs, computes WCAG AA contrast ratios for 24 semantic text-on-background
|
||||||
|
pairings per theme (48 total). Uses AA (4.5:1) for normal text, AA-lg (3.0:1)
|
||||||
|
for large/bold text contexts.
|
||||||
|
- **`scripts/generate-coverage-matrix.sh`** — 12 kernel primitives × all surfaces
|
||||||
|
markdown table. Flags any deprecated component usage (`.btn-primary`, etc.).
|
||||||
|
- **`scripts/audit-touch-targets.sh`** — static analysis for 44px minimum mobile
|
||||||
|
touch targets on close buttons and interactive elements.
|
||||||
|
- **`docs/USABILITY-SURVEY.md`** — structured 8-section prompt (viewport, banners,
|
||||||
|
responsive, styling, contrast, touch targets, focus indicators, component
|
||||||
|
uniformity) with pass/fail criteria and file paths for automated execution.
|
||||||
|
- **Focus indicators** — `:focus-visible` styles on `.sw-btn`, `.sw-input`,
|
||||||
|
`.sw-dropdown__trigger`, `.sw-menu__item`, `.sw-tabs__tab`.
|
||||||
|
- **Mobile touch targets** — `min-width/min-height: 44px` in `@media (max-width:
|
||||||
|
768px)` for `.sw-banner__close`, `.sw-toast__close`, `.sw-dialog__close`,
|
||||||
|
`.sw-drawer__close`, `.sw-tabs__arrow`, `.modal-close`, `.sw-tabs__tab`,
|
||||||
|
`.sw-dropdown__option`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **WCAG contrast violations** — dark-mode accent darkened from `#6c9fff` to
|
||||||
|
`#6493ed` (3.03:1 with white text), dark-mode success from `#22c55e` to
|
||||||
|
`#1dab51` (3.00:1). Light-mode `--text-3` darkened from `#8b8da3` to `#787a92`.
|
||||||
|
Light-mode `--success-light` and `--warning-light` darkened for badge contrast.
|
||||||
|
All 48 pairings now pass.
|
||||||
|
- **Docker Hub references** — `docs/DEPLOYMENT.md` and `docs/DISTRIBUTION.md`
|
||||||
|
corrected from `ghcr.io/armature/armature` to `gobha/armature` (Docker Hub).
|
||||||
|
Builder image corrected to `gobha/armature-builder`. GitHub URL corrected to
|
||||||
|
`github.com/gobha/armature`.
|
||||||
|
|
||||||
|
## v0.6.15 — User Display Audit
|
||||||
|
|
||||||
|
Every user-facing identity surface now shows human-readable names instead of
|
||||||
|
UUIDs, with the canonical fallback chain: `display_name → username → "Unknown"`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`GET /api/v1/users/resolve?ids=...`** — batch endpoint returns identity
|
||||||
|
records (username, display_name, handle, avatar_url) for up to 100 user IDs.
|
||||||
|
Response keyed by ID for O(1) client lookups.
|
||||||
|
- **`sw.users` SDK module** — `resolve(id)`, `resolveMany(ids)`,
|
||||||
|
`displayName(user)` with 60-second local cache. Surfaces use this instead
|
||||||
|
of ad-hoc lookups or stale snapshots.
|
||||||
|
- **5 handler tests** for the resolve endpoint (single, multiple, missing,
|
||||||
|
empty, no-param).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Admin users list** — shows `display_name || username` as primary
|
||||||
|
identifier, with username shown as secondary when display_name is set.
|
||||||
|
- **Admin teams/groups member lists** — replaced `username || user_id`
|
||||||
|
with `display_name || username || 'Unknown'`.
|
||||||
|
- **Team-admin members** — dropdown and list now show display_name.
|
||||||
|
- **Chat participants** — resolved from users table via `sw.users.resolveMany()`
|
||||||
|
instead of relying on creation-time snapshot. Message sender names, typing
|
||||||
|
indicators, and participant sidebar all use resolved names.
|
||||||
|
- **Dashboard greeting** — added `|| 'Unknown'` terminal fallback.
|
||||||
|
- **Team activity log** — capitalized fallback to `'Unknown'`.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- **`participants.display_name` column** in chat-core — column retained for
|
||||||
|
backward compatibility but UI no longer relies on snapshot values. Comments
|
||||||
|
added to `packages/chat-core/script.star` noting deprecation.
|
||||||
|
|
||||||
|
## v0.6.14 — Visual Polish
|
||||||
|
|
||||||
|
Systematic cleanup of stale values, self-hosted fonts, and rendering fixes.
|
||||||
|
Final visual pass before the v0.6.15 usability survey gate.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **v0.6.13 spacing regressions** — added half-step tokens (`--sp-1h` 6px,
|
||||||
|
`--sp-2h` 10px) and restored correct padding on `.sw-btn--sm`, `.sw-input`,
|
||||||
|
`.sw-menu__item`, `.sw-dropdown__option`, `.sw-tabs__tab`.
|
||||||
|
- **Theme settings toggle** — showed resolved theme ("Dark") instead of stored
|
||||||
|
mode ("System"). Changed `appearance.js` to read `sw.theme.mode`.
|
||||||
|
- **Notes surface scrollbar** — added `overflow: hidden` to `.surface-inner`
|
||||||
|
in `base.html`, preventing spurious scrollbar at any scale.
|
||||||
|
- **Chat input clipped at high scale** — same `overflow: hidden` fix prevents
|
||||||
|
zoomed content from overflowing the surface container.
|
||||||
|
- **User menu drift at scale > 100%** — `menu.js` now divides
|
||||||
|
`getBoundingClientRect()` coords by the CSS zoom factor, fixing position
|
||||||
|
for `position: fixed` menus inside a zoomed ancestor.
|
||||||
|
- **Undefined variables** — `--text-secondary` (login), `--text-muted`
|
||||||
|
(user picker), `--text-1` (settings toggle) replaced with correct tokens.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Stale fallback colors purged** — removed ~65 hex/rgba fallback values
|
||||||
|
from `var()` calls across 9 kernel CSS files and 3 extension packages.
|
||||||
|
Old gold theme color `#b38a4e` fully eliminated (9 instances).
|
||||||
|
- **Self-hosted fonts** — bundled DM Sans and JetBrains Mono woff2 files
|
||||||
|
in `src/fonts/`. Replaced Google Fonts `@import` and login.html `<link>`
|
||||||
|
with local `@font-face` declarations. Zero external font dependencies.
|
||||||
|
- **Consistent border-radius** — added `--radius-sm: 4px` token. Migrated
|
||||||
|
~60 hardcoded `border-radius` values across all kernel CSS and 12 extension
|
||||||
|
packages to three tokens: `--radius-sm` (4px), `--radius` (8px),
|
||||||
|
`--radius-lg` (12px).
|
||||||
|
|
||||||
|
### Updated
|
||||||
|
|
||||||
|
- `docs/EXTENSION-CSS.md` — added `--sp-1h`, `--sp-2h` half-step tokens
|
||||||
|
and `--radius-sm` to the public CSS contract.
|
||||||
|
|
||||||
|
## v0.6.13 — Responsive & Spacing
|
||||||
|
|
||||||
|
Spacing token scale and tablet breakpoint. All kernel CSS and extension
|
||||||
|
packages migrated from hardcoded values to design tokens.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Spacing tokens** (`--sp-1` through `--sp-12`) — 4px-grid scale in
|
||||||
|
`variables.css`. Nine stops: 4, 8, 12, 16, 20, 24, 32, 40, 48px.
|
||||||
|
Numeric naming (`--sp-N`), rem-based for zoom/font-size respect.
|
||||||
|
- **Tablet breakpoint** (`max-width: 1024px`) — new responsive tier
|
||||||
|
between mobile (768px) and desktop. Secondary workspace pane narrows
|
||||||
|
to 360px, admin sidebar to 120px, settings/admin/editor navs shrink.
|
||||||
|
- **Breakpoint documentation** in `EXTENSION-CSS.md` — Mobile (768px),
|
||||||
|
Tablet (1024px), Desktop (default).
|
||||||
|
- **Spacing guidelines** in `EXTENSION-CSS.md` — token table with
|
||||||
|
computed pixel values and usage examples.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **8 kernel CSS files** migrated to spacing tokens — `sw-primitives.css`,
|
||||||
|
`modals.css`, `surfaces.css`, `layout.css`, `sw-shell.css`,
|
||||||
|
`primitives.css`, `user-menu.css`, `sw-login.css`. Hardcoded padding,
|
||||||
|
margin, and gap values replaced with `var(--sp-N)`.
|
||||||
|
- **12 extension packages** migrated — chat, dashboard, editor,
|
||||||
|
git-board, hello-dashboard, icd-test-runner, notes, schedules,
|
||||||
|
sdk-test-runner, tasks, team-activity-log, workflow-demo.
|
||||||
|
- **Login hero breakpoint** normalized from 900px to 1024px (tablet).
|
||||||
|
- **Notes mobile breakpoint** normalized from 700px to 768px (standard).
|
||||||
|
|
||||||
|
## v0.6.12 — Extension CSS Isolation
|
||||||
|
|
||||||
|
Prefix enforcement prevents extension CSS from leaking into the kernel or
|
||||||
|
sibling extensions. All 12 in-tree packages migrated to `.ext-{slug}-*`
|
||||||
|
naming convention.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`data-ext` attribute** on extension mount container — enables scoped
|
||||||
|
selectors like `[data-ext="chat"] .ext-chat-app`.
|
||||||
|
- **CSS linter** (`scripts/lint-package-css.sh`) — validates that the first
|
||||||
|
class selector in every extension CSS rule starts with `.ext-{slug}`.
|
||||||
|
Exempts `:root`, `@keyframes`, `@font-face`, `@media`, kernel `.sw-*`
|
||||||
|
classes, and CodeMirror `.cm-*` classes.
|
||||||
|
- **Kernel CSS contract** (`docs/EXTENSION-CSS.md`) — documents stable
|
||||||
|
public classes and CSS variables that extensions may reference. Everything
|
||||||
|
else is internal kernel CSS.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **12 packages migrated** — all class selectors renamed to `.ext-{slug}-*`:
|
||||||
|
chat, dashboard, editor, git-board, hello-dashboard, icd-test-runner,
|
||||||
|
notes, schedules, sdk-test-runner, tasks, team-activity-log, workflow-demo.
|
||||||
|
CSS and JS files updated in lockstep.
|
||||||
|
- **`icd-test-runner`** — ID selectors (`#extension-mount`) converted to
|
||||||
|
class-based selectors with proper prefix.
|
||||||
|
- **`editor` cross-references** — compound selectors referencing notes
|
||||||
|
classes updated to new `.ext-notes-*` names.
|
||||||
|
- **`chat` kernel overrides** — `.sw-dialog:has(...)` override scoped under
|
||||||
|
`[data-ext="chat"]` instead of global.
|
||||||
|
|
||||||
|
## v0.6.11 — CSS Deduplication
|
||||||
|
|
||||||
|
One class per concept. The old `primitives.css` button, toast, popup-menu,
|
||||||
|
dropdown, and tabs systems are retired. `sw-primitives.css` is the single
|
||||||
|
source of truth for all Preact component styles.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Buttons**: All 29 files migrated from `.btn-primary` / `.btn-small` /
|
||||||
|
`.btn-danger` / `.btn-ghost` / `.btn-md` / `.btn-sm` to the BEM-style
|
||||||
|
`.sw-btn .sw-btn--{variant} .sw-btn--{size}` system.
|
||||||
|
- **Toasts**: Old `.toast-container` / `.toast` CSS deleted. SDK's
|
||||||
|
`sw.toast()` API already used `.sw-toast-*` classes — no JS changes.
|
||||||
|
- **Popup menus**: Old `.popup-menu` / `.popup-menu-item` CSS deleted
|
||||||
|
(unused — `.sw-menu` is the active system).
|
||||||
|
- **Dropdown collision resolved**: Old `.sw-dropdown` (styled `<select>`)
|
||||||
|
deleted from `primitives.css`. The `sw-primitives.css` custom dropdown
|
||||||
|
component (`.sw-dropdown` with BEM sub-elements) is authoritative.
|
||||||
|
- **Tabs collision resolved**: Old `.sw-tabs` / `.sw-tab-btn` deleted from
|
||||||
|
`primitives.css`. The `sw-primitives.css` scrollable tabs component
|
||||||
|
(`.sw-tabs__tab`) is authoritative.
|
||||||
|
- **`.settings-section` collision resolved**: Removed duplicate definition
|
||||||
|
from `modals.css`. The `surfaces.css` card-style definition is
|
||||||
|
authoritative; `.settings-content .settings-section` override resets
|
||||||
|
card styling for flat settings layouts.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `.sw-btn--success` variant in `sw-primitives.css` (green action button).
|
||||||
|
- `--bg-active` CSS variable in both dark/light themes (`variables.css`).
|
||||||
|
- `scripts/audit-css-collisions.sh` — finds duplicate class selectors
|
||||||
|
across kernel CSS files and outputs a JSON collision report.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `packages/sdk-test-runner/css/main.css`: Wrong variable names
|
||||||
|
(`--text3` → `--text-3`, `--text2` → `--text-2`, `--bg1`/`--bg2` →
|
||||||
|
`--bg-raised`).
|
||||||
|
- `packages/icd-test-runner/css/main.css`: Replaced inline button fallback
|
||||||
|
styles with kernel `.sw-btn` system.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Old button classes: `.btn-primary`, `.btn-small`, `.btn-danger`,
|
||||||
|
`.btn-full`, `.btn-ghost`, `.btn-subtle`, `.btn-sm`, `.btn-md`.
|
||||||
|
- Old toast classes: `.toast-container`, `.toast`, `.toast.error/warning/success`.
|
||||||
|
- Old popup menu classes: `.popup-menu`, `.popup-menu-item`, `.popup-menu-*`.
|
||||||
|
- Old dropdown and tabs definitions from `primitives.css` that collided
|
||||||
|
with `sw-primitives.css`.
|
||||||
|
|
||||||
|
## v0.6.10 — Viewport Foundation
|
||||||
|
|
||||||
|
Single layout model. Every surface renders inside one containment chain:
|
||||||
|
`body → shell → surface`. No dual systems. No transform hacks.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **CSS `zoom` replaces `transform: scale()`**: UI scale (80%–175%) now
|
||||||
|
uses CSS `zoom` on `#surfaceInner` instead of `transform: scale()`.
|
||||||
|
`zoom` reflows layout correctly — `getBoundingClientRect()` returns
|
||||||
|
accurate values, eliminating the scale-correction hack in `menu.js`.
|
||||||
|
Supported in all evergreen browsers (Firefox 126+, June 2024).
|
||||||
|
- **Single layout root**: `<body>` in `base.html` is the authoritative
|
||||||
|
flex column layout. `.sw-shell` CSS demoted from viewport-level
|
||||||
|
container (`height: 100vh`) to fill-parent (`height: 100%`).
|
||||||
|
Safe-area insets moved from `.sw-shell` to `<body>`.
|
||||||
|
- **Banner single source of truth**: Template banners measure their own
|
||||||
|
height via inline `<script>` and set `--banner-top-height` /
|
||||||
|
`--banner-bottom-height` CSS variables. Removed `--banner-h: 28px`
|
||||||
|
fixed variable. `ShellBanner` Preact component's `useEffect`
|
||||||
|
measurement removed (dead code — no surface imports `AppShell`).
|
||||||
|
- **`sw-shell__banner` position**: Changed from `position: fixed` to
|
||||||
|
`position: static` — template banners are in-flow elements.
|
||||||
|
- **`sw-shell__body` padding**: Removed `padding-top/bottom` for
|
||||||
|
banner offsets — unnecessary with in-flow banners.
|
||||||
|
- **Extension surfaces `100vh` → `100%`**: `chat-app`, `chat-loading`,
|
||||||
|
`surface-dashboard` now use `height: 100%` to inherit from the
|
||||||
|
extension mount container (like Notes). Fixes overflow behind banners.
|
||||||
|
- **`100vh` → `100dvh` fallbacks**: All viewport-height declarations
|
||||||
|
use `height: 100vh; height: 100dvh;` pattern for correct behavior on
|
||||||
|
mobile browsers. Affects: `base.html`, `sw-login.css`,
|
||||||
|
`workflow.html`, `workflow-landing.html`, `primitives.css`,
|
||||||
|
`git-board/css/main.css`.
|
||||||
|
- **`sw.shell.getScale()` deprecated**: Returns `1` always — CSS `zoom`
|
||||||
|
handles layout reflow without manual correction.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
- `src/js/sw/shell/app-shell.js`, `app.js`, `surface-viewport.js` —
|
||||||
|
no surface imports these. Layout root is `<body>` in `base.html`.
|
||||||
|
|
||||||
|
## v0.6.9 — Session Lifetime Config
|
||||||
|
|
||||||
|
Admin-configurable session durations, "keep me logged in" opt-in, and
|
||||||
|
optional idle timeout. Completes the auth hardening started in v0.6.8.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Admin session settings**: `session.access_token_ttl` (default 15m,
|
||||||
|
clamp 5m–60m) and `session.refresh_token_ttl` (default 7d, clamp
|
||||||
|
1h–90d) stored in `global_settings`. New `LoadSessionConfig()` helper
|
||||||
|
reads and clamps values with `parseDurationString()` supporting `m`,
|
||||||
|
`h`, `d` suffixes.
|
||||||
|
- **"Keep me logged in" checkbox**: Login form opt-in. Checked = full
|
||||||
|
`refresh_token_ttl`. Unchecked = capped at 24h. Cookie `max-age`
|
||||||
|
tracks whichever lifetime was chosen. `keep_login` flag stored on
|
||||||
|
refresh token row.
|
||||||
|
- **Config-driven token generation**: `generateTokens()` reads TTLs from
|
||||||
|
`global_settings` instead of hardcoded `15*time.Minute` /
|
||||||
|
`7*24*time.Hour`. Response includes `expires_in` and
|
||||||
|
`refresh_expires_in` (seconds) so the client schedules refresh
|
||||||
|
correctly.
|
||||||
|
- **Idle timeout (optional)**: Admin-toggleable (default off). Server
|
||||||
|
checks `last_activity_at` on refresh-token row; rejects if gap exceeds
|
||||||
|
`session.idle_timeout`. Client SDK pings `POST /api/v1/auth/activity`
|
||||||
|
on click/keydown (debounced, max 1/min).
|
||||||
|
- **Admin Settings > Session section**: Dropdowns for access TTL, refresh
|
||||||
|
TTL, and idle timeout with toggle.
|
||||||
|
- **7 new tests**: Duration parsing, clamping (low/high), defaults,
|
||||||
|
invalid values, empty idle timeout.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `CreateRefreshToken` store method now accepts `keepLogin bool`
|
||||||
|
parameter; both Postgres and SQLite implementations updated.
|
||||||
|
- `GetRefreshTokenInfo` returns `RefreshTokenInfo` struct with
|
||||||
|
`UserID`, `KeepLogin`, `LastActivityAt` for idle-timeout decisions.
|
||||||
|
- SDK `auth.login()` accepts optional third `keepLogin` parameter;
|
||||||
|
cookie max-age derived from server `refresh_expires_in` response.
|
||||||
|
- SDK boots activity tracking after successful auth boot.
|
||||||
|
|
||||||
|
## v0.6.8 — Cookie Fix + UI Hardening Roadmap
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Session cookie max-age bug**: `arm_token` cookie was set to 15 min
|
||||||
|
(matching access token) while refresh token lasted 7 days. Cookie now
|
||||||
|
matches refresh token lifetime so Go SSR middleware can serve page
|
||||||
|
shells while JS refreshes the access token client-side.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **ROADMAP-UI.md**: Detailed UI hardening roadmap (v0.6.9–v0.6.15)
|
||||||
|
covering session config, viewport foundation, CSS deduplication,
|
||||||
|
extension CSS isolation, responsive layout, visual polish, and
|
||||||
|
automated usability survey gate.
|
||||||
|
|
||||||
## v0.6.7 — Native mTLS
|
## v0.6.7 — Native mTLS
|
||||||
|
|
||||||
End-to-end mutual TLS without a reverse proxy. Targets systemd+podman
|
End-to-end mutual TLS without a reverse proxy. Targets systemd+podman
|
||||||
|
|||||||
151
ROADMAP-UI.md
Normal file
151
ROADMAP-UI.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# Armature — v0.6.x UI Hardening Roadmap
|
||||||
|
|
||||||
|
> **Goal**: Fix all viewport scaling, banner integration, and styling inconsistencies
|
||||||
|
> so Claude Code can run a meaningful automated usability survey against a
|
||||||
|
> stable, uniform UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Inventory
|
||||||
|
|
||||||
|
### P1 — Viewport & Scaling (blocks everything)
|
||||||
|
|
||||||
|
| # | Issue | Where | Impact |
|
||||||
|
|---|-------|-------|--------|
|
||||||
|
| 1 | **Dual layout systems** — `base.html` template has its own banner→surface→footer column; `app-shell.js` Preact shell has a separate `sw-shell` → `sw-shell__body` → `sw-shell__surface` column. They never coordinate. | `server/pages/templates/base.html` ↔ `src/js/sw/shell/app-shell.js` | Every surface inherits an ambiguous viewport ancestor. |
|
||||||
|
| 2 | **Transform-based scaling is broken** — `transform: scale()` on `#surfaceInner` doesn't reflow layout. Breaks `getBoundingClientRect` (menu.js already carries a scale-correction hack at line 20), scroll containment, pointer events, and the inverse-width/height hack (`100/s%`) doesn't account for banners consuming viewport space. | `base.html:56-67`, `appearance.js:41-53`, `menu.js:20-33` | Menus, tooltips, dropdowns all mis-positioned at any non-100% scale. Click targets wrong. |
|
||||||
|
| 3 | **Banner height desync** — `base.html` hardcodes `--banner-h: 28px` and renders banners in-flow. `app-shell.js` ShellBanner measures `offsetHeight` into `--banner-top-height` / `--banner-bottom-height` and uses `position: fixed` + body padding. Two independent banner systems. | `base.html:31,72-75,103-107` ↔ `app-shell.js:25-42`, `sw-shell.css:22-37,63-69` | With banners enabled, content overflows by the banner height or gets double-inset depending on which code path is active. |
|
||||||
|
| 4 | **`100vh` on mobile** — `.sw-shell`, `.chat-app`, `.chat-loading`, `.login-shell` use `100vh` which doesn't subtract mobile browser chrome (address bar, toolbar). | `sw-shell.css:9`, `chat/css/main.css:15,25`, `sw-login.css:7` | Content overflows on iOS Safari, Android Chrome. |
|
||||||
|
| 5 | **Chat uses `100vh` not `100%`** — `.chat-app { height: 100vh }` ignores parent container (which already excludes banners/footer). Notes correctly uses `height: 100%`. Every extension surface that copies chat's pattern will inherit the bug. | `packages/chat/css/main.css:15` | Chat surface overflows behind banners. |
|
||||||
|
|
||||||
|
### P2 — CSS Architecture
|
||||||
|
|
||||||
|
| # | Issue | Where | Impact |
|
||||||
|
|---|-------|-------|--------|
|
||||||
|
| 6 | **Class name collisions** — `.settings-section` defined in both `modals.css:41-43` and `surfaces.css:75-83` with different padding, margin, and border-bottom rules. `.sw-dropdown` in `primitives.css:443` (styled `<select>`) vs `sw-primitives.css:262` (custom dropdown component). `.sw-tabs` in `primitives.css:459` vs `sw-primitives.css:234`. | Kernel CSS | Styles randomly win depending on load order. Bug reports differ by surface. |
|
||||||
|
| 7 | **Old/new primitive duplication** — Two button systems (`.btn-primary`/`.btn-small` in `primitives.css` vs `.sw-btn--primary`/`.sw-btn--sm` in `sw-primitives.css`). Two toast systems (`.toast` vs `.sw-toast`). Two dropdown, two tabs implementations. | `primitives.css` ↔ `sw-primitives.css` | No single source of truth for any component. Extension authors can't know which to use. |
|
||||||
|
| 8 | **Extension CSS bleeds globally** — Extension `main.css` loaded via `<link>` at document level, no scoping. Any class name can override kernel or sibling extension styles. | `base.html:26-28`, `extension-surface.css` | Package CSS fights with kernel. Two packages with `.sidebar` both lose. |
|
||||||
|
| 9 | **No intermediate breakpoint** — Only `768px` mobile breakpoint. No tablet (768–1024px). Secondary workspace pane hardcoded `480px`. Admin nav hardcoded `200px`. | `layout.css:263`, `surfaces.css:305` | Cramped layout on iPad/small laptops. |
|
||||||
|
|
||||||
|
### P3 — Visual Consistency
|
||||||
|
|
||||||
|
| # | Issue | Where | Impact |
|
||||||
|
|---|-------|-------|--------|
|
||||||
|
| 10 | **Mixed unit systems** — `px`, `rem`, `em` used interchangeably with no rationale. Some font-sizes in `px` (primitives), others in `rem` (sw-primitives), others in `em` (user-picker). | All CSS files | Zoom/scale behaves differently per element. |
|
||||||
|
| 11 | **Google Fonts CDN dependency** — `@import url(...)` in `variables.css:5` blocks rendering if CDN is slow; breaks entirely on air-gapped deployments. | `variables.css:5` | FOUT or blank page on slow networks. No offline support. |
|
||||||
|
| 12 | **No spacing scale** — Padding/margins are ad-hoc values (10px, 12px, 14px, 16px, 20px, 24px, 28px…). No design-token spacing scale. | All CSS | Inconsistent visual rhythm across surfaces. |
|
||||||
|
| 13 | **Hardcoded fallback colors in component CSS** — `sw-shell.css`, `sw-primitives.css` have inline fallbacks like `var(--accent, #b38a4e)` — a gold color from a previous theme that doesn't match the current blue `#6c9fff`. | `sw-shell.css`, `sw-primitives.css` | Wrong colors flash briefly if variables load late. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
### v0.6.9 — Session Lifetime Config ✅
|
||||||
|
|
||||||
|
Shipped. Admin-configurable TTLs, "keep me logged in" checkbox, idle timeout,
|
||||||
|
config-driven `generateTokens()`, 7 new tests. See CHANGELOG.md for details.
|
||||||
|
|
||||||
|
### v0.6.10 — Viewport Foundation ✅
|
||||||
|
|
||||||
|
Shipped. Single layout model: `body → shell → surface`. CSS `zoom` replaces
|
||||||
|
`transform: scale()`. `100dvh` fallbacks everywhere. Extension surfaces use
|
||||||
|
`100%` instead of `100vh`. Dead shell layout code deprecated. See CHANGELOG.md.
|
||||||
|
|
||||||
|
### v0.6.11 — CSS Deduplication ✅
|
||||||
|
|
||||||
|
Shipped. Old primitive system retired. One class per concept. See CHANGELOG.md.
|
||||||
|
|
||||||
|
| Step | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| Audit collision inventory | Script that finds all duplicate class selectors across kernel CSS files. Produce a machine-readable collision report (JSON). |
|
||||||
|
| Migrate `.settings-section` | Remove `modals.css:41-43` definition. The `surfaces.css` definition is authoritative. Modals that used the old styles get explicit overrides scoped to `.modal .settings-section`. |
|
||||||
|
| Retire `primitives.css` old components | `.btn-primary`, `.btn-small`, `.btn-danger`, `.btn-full` → migrate all usages to `.sw-btn--*`. `.toast-container` / `.toast` → migrate to `.sw-toast-container` / `.sw-toast`. Old `.popup-menu` → migrate to `.sw-menu`. Old `.sw-dropdown` (styled select in primitives.css) → rename to `.sw-native-select` or delete if unused. Old `.sw-tabs` / `.sw-tab-btn` in primitives.css → delete (sw-primitives.css version is authoritative). |
|
||||||
|
| Mark deprecated classes | Any remaining old classes get a `/* DEPRECATED v0.6.11 — use .sw-btn--* */` comment and a 1-version grace period for package authors. |
|
||||||
|
| Package CSS audit | Scan all `packages/*/css/main.css` for references to deprecated kernel classes. Fix in-tree packages. |
|
||||||
|
|
||||||
|
### v0.6.12 — Extension CSS Isolation ✅
|
||||||
|
|
||||||
|
Shipped. Prefix enforcement via linter. All 12 in-tree packages migrated.
|
||||||
|
See CHANGELOG.md.
|
||||||
|
|
||||||
|
| Step | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| Scoping strategy | Prefix enforcement (option B). `@scope` support still patchy; prefix works everywhere, trivially lintable. |
|
||||||
|
| Linter | `scripts/lint-package-css.sh` — validates first class selector in every rule starts with `.ext-{slug}`. Exempts `:root`, `@keyframes`, `@font-face`, `@media`, `.sw-*` kernel classes, `.cm-*` CodeMirror classes. |
|
||||||
|
| Kernel CSS contract | `docs/EXTENSION-CSS.md` — stable public classes (`.sw-btn--*`, `.sw-input`, `.sw-field`, `.sw-dialog`, `.sw-toast`, `.sw-menu`, `.sw-tabs`, etc.) and all CSS variables. Everything else internal. |
|
||||||
|
| Migrate in-tree packages | `data-ext="{{.Surface}}"` on extension mount. All 12 packages prefixed to `.ext-{slug}-*`. CSS + JS updated in lockstep. |
|
||||||
|
|
||||||
|
### v0.6.13 — Responsive & Spacing ✅
|
||||||
|
|
||||||
|
Shipped. Spacing token scale (`--sp-1` through `--sp-12`, 4px grid) in
|
||||||
|
`variables.css`. Tablet breakpoint (`max-width: 1024px`) in layout, surfaces,
|
||||||
|
login. All kernel CSS and 12 in-tree packages migrated to spacing tokens.
|
||||||
|
`EXTENSION-CSS.md` updated with spacing table and breakpoint guide.
|
||||||
|
Font-size scale deferred to v0.6.14. See CHANGELOG.md.
|
||||||
|
|
||||||
|
### v0.6.14 — Visual Polish ✅
|
||||||
|
|
||||||
|
Shipped. Stale fallback colors purged (~65 instances), fonts self-hosted
|
||||||
|
(DM Sans + JetBrains Mono woff2), border-radius standardized to three tokens
|
||||||
|
(`--radius-sm`, `--radius`, `--radius-lg`). Also fixed v0.6.13 spacing
|
||||||
|
regressions (half-step tokens), theme settings default, surface overflow,
|
||||||
|
and user menu zoom drift. See CHANGELOG.md.
|
||||||
|
|
||||||
|
### v0.6.15 — User Display Audit ✅
|
||||||
|
|
||||||
|
Shipped. `GET /api/v1/users/resolve?ids=...` batch endpoint, `sw.users`
|
||||||
|
SDK module with `resolve()`, `resolveMany()`, `displayName()` + 60s cache.
|
||||||
|
Chat participants resolved from users table instead of snapshot. All admin
|
||||||
|
surfaces show `display_name || username || 'Unknown'`. Chat-core snapshot
|
||||||
|
column deprecated. 5 new handler tests. See CHANGELOG.md.
|
||||||
|
|
||||||
|
### v0.6.16 — Usability Survey Gate ✅
|
||||||
|
|
||||||
|
Shipped. Four audit scripts, structured survey prompt, contrast/touch-target
|
||||||
|
fixes, Docker docs corrected. All 48 contrast pairings pass, all close buttons
|
||||||
|
have 44px mobile targets, focus-visible on all key primitives. See CHANGELOG.md.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sequencing Rationale
|
||||||
|
|
||||||
|
```
|
||||||
|
v0.6.9 Session Lifetime Config ✅ SHIPPED
|
||||||
|
↓
|
||||||
|
v0.6.10 Viewport Foundation ✅ SHIPPED
|
||||||
|
↓
|
||||||
|
v0.6.11 CSS Deduplication ✅ SHIPPED
|
||||||
|
↓
|
||||||
|
v0.6.12 Extension CSS Isolation ✅ SHIPPED
|
||||||
|
↓
|
||||||
|
v0.6.13 Responsive & Spacing ✅ SHIPPED
|
||||||
|
↓
|
||||||
|
v0.6.14 Visual Polish ✅ SHIPPED
|
||||||
|
↓
|
||||||
|
v0.6.15 User Display Audit ✅ SHIPPED
|
||||||
|
↓
|
||||||
|
v0.6.16 Usability Survey Gate ✅ SHIPPED
|
||||||
|
```
|
||||||
|
|
||||||
|
Each version is independently shippable and testable. No version depends on
|
||||||
|
anything after it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What "Usability Survey by Code" Means
|
||||||
|
|
||||||
|
The v0.6.16 deliverables give Claude Code (or any automated tool) three things:
|
||||||
|
|
||||||
|
1. **A structured inventory** (`ui-inventory.json`) of every component, on every
|
||||||
|
surface, with its CSS file and line number.
|
||||||
|
|
||||||
|
2. **Automated checks** (contrast, touch targets, token usage) that produce
|
||||||
|
machine-readable pass/fail reports.
|
||||||
|
|
||||||
|
3. **A survey prompt** (`USABILITY-SURVEY.md`) with explicit criteria and file
|
||||||
|
paths, so Claude Code can `cat` the relevant files, run the scripts, and
|
||||||
|
produce a scored report without human guidance.
|
||||||
|
|
||||||
|
The survey is not a substitute for real user testing — it's a structural
|
||||||
|
quality gate that catches the class of bugs (overflow, contrast, misalignment,
|
||||||
|
inconsistent styling) that have historically slipped through.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Armature — Roadmap
|
# Armature — Roadmap
|
||||||
|
|
||||||
## Current: v0.6.7 — Native mTLS
|
## Current: v0.6.14 — Visual Polish
|
||||||
|
|
||||||
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
|
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
|
||||||
storage, realtime, and ops are kernel primitives. Everything else is an extension.
|
storage, realtime, and ops are kernel primitives. Everything else is an extension.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ services:
|
|||||||
EXT_ALLOW_PRIVATE_IPS: ${EXT_ALLOW_PRIVATE_IPS:-true}
|
EXT_ALLOW_PRIVATE_IPS: ${EXT_ALLOW_PRIVATE_IPS:-true}
|
||||||
LOG_FORMAT: ${LOG_FORMAT:-text}
|
LOG_FORMAT: ${LOG_FORMAT:-text}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
BUNDLED_PACKAGES: ${BUNDLED_PACKAGES:-}
|
BUNDLED_PACKAGES: ${BUNDLED_PACKAGES:-*}
|
||||||
# Dev seed users — ignored if ENVIRONMENT=production
|
# Dev seed users — ignored if ENVIRONMENT=production
|
||||||
SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user}
|
SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
## Docker Single-Instance
|
## Docker Single-Instance
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/armature/armature:latest
|
docker pull gobha/armature:latest
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e ARMATURE_ADMIN_USERNAME=admin \
|
-e ARMATURE_ADMIN_USERNAME=admin \
|
||||||
-e ARMATURE_ADMIN_PASSWORD=changeme \
|
-e ARMATURE_ADMIN_PASSWORD=changeme \
|
||||||
-e JWT_SECRET="$(openssl rand -hex 32)" \
|
-e JWT_SECRET="$(openssl rand -hex 32)" \
|
||||||
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
||||||
-v armature-data:/data \
|
-v armature-data:/data \
|
||||||
ghcr.io/armature/armature:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
This runs with SQLite and PVC storage. Suitable for evaluation and small teams.
|
This runs with SQLite and PVC storage. Suitable for evaluation and small teams.
|
||||||
@@ -29,7 +29,7 @@ services:
|
|||||||
- pg_data:/var/lib/postgresql/data
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
armature:
|
armature:
|
||||||
image: ghcr.io/armature/armature:latest
|
image: gobha/armature:latest
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/armature/armature:latest
|
docker pull gobha/armature:latest
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e ARMATURE_ADMIN_USERNAME=admin \
|
-e ARMATURE_ADMIN_USERNAME=admin \
|
||||||
-e ARMATURE_ADMIN_PASSWORD=changeme \
|
-e ARMATURE_ADMIN_PASSWORD=changeme \
|
||||||
ghcr.io/armature/armature:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
On first run, bundled packages are automatically installed — workflows, surfaces, and extensions are ready to use immediately.
|
On first run, bundled packages are automatically installed — workflows, surfaces, and extensions are ready to use immediately.
|
||||||
@@ -66,12 +66,12 @@ Set `BUNDLED_PACKAGES` to control which packages are installed:
|
|||||||
# Install ALL packages (everything in the image)
|
# Install ALL packages (everything in the image)
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e BUNDLED_PACKAGES="*" \
|
-e BUNDLED_PACKAGES="*" \
|
||||||
ghcr.io/armature/armature:latest
|
gobha/armature:latest
|
||||||
|
|
||||||
# Install specific packages only
|
# Install specific packages only
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e BUNDLED_PACKAGES="notes,tasks,schedules" \
|
-e BUNDLED_PACKAGES="notes,tasks,schedules" \
|
||||||
ghcr.io/armature/armature:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
Empty (default) installs the curated default set. Use `*` to install all packages. This is useful for Helm charts where different environments need different packages.
|
Empty (default) installs the curated default set. Use `*` to install all packages. This is useful for Helm charts where different environments need different packages.
|
||||||
@@ -83,7 +83,7 @@ Set `SKIP_BUNDLED_PACKAGES=true` to prevent bundled packages from being installe
|
|||||||
```bash
|
```bash
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e SKIP_BUNDLED_PACKAGES=true \
|
-e SKIP_BUNDLED_PACKAGES=true \
|
||||||
ghcr.io/armature/armature:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Custom Bundle Directory
|
### Custom Bundle Directory
|
||||||
@@ -94,7 +94,7 @@ Override the default bundled packages location with `BUNDLED_PACKAGES_DIR`:
|
|||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e BUNDLED_PACKAGES_DIR=/custom/packages \
|
-e BUNDLED_PACKAGES_DIR=/custom/packages \
|
||||||
-v /host/packages:/custom/packages \
|
-v /host/packages:/custom/packages \
|
||||||
ghcr.io/armature/armature:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
## Builder Image
|
## Builder Image
|
||||||
@@ -102,7 +102,7 @@ docker run -p 8080:80 \
|
|||||||
The builder image pre-caches Go modules and Node dependencies for faster custom builds.
|
The builder image pre-caches Go modules and Node dependencies for faster custom builds.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/armature/builder:latest
|
docker pull gobha/armature-builder:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### What It Caches
|
### What It Caches
|
||||||
@@ -117,7 +117,7 @@ docker pull ghcr.io/armature/builder:latest
|
|||||||
Reference the builder image as a base stage in your Dockerfile:
|
Reference the builder image as a base stage in your Dockerfile:
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
FROM ghcr.io/armature/builder:latest AS builder
|
FROM gobha/armature-builder:latest AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY server/ .
|
COPY server/ .
|
||||||
RUN go build -ldflags="-s -w" -o /bin/armature .
|
RUN go build -ldflags="-s -w" -o /bin/armature .
|
||||||
@@ -148,7 +148,7 @@ To exclude specific packages from the bundle, either:
|
|||||||
### Forking for Custom Builds
|
### Forking for Custom Builds
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/armature/armature.git
|
git clone https://github.com/gobha/armature.git
|
||||||
cd armature
|
cd armature
|
||||||
|
|
||||||
# Add/modify packages
|
# Add/modify packages
|
||||||
@@ -189,13 +189,13 @@ docker run -p 8080:80 \
|
|||||||
-e DATABASE_URL="postgres://user:pass@host:5432/armature?sslmode=require" \
|
-e DATABASE_URL="postgres://user:pass@host:5432/armature?sslmode=require" \
|
||||||
-e JWT_SECRET="$(openssl rand -hex 32)" \
|
-e JWT_SECRET="$(openssl rand -hex 32)" \
|
||||||
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
|
||||||
ghcr.io/armature/armature:latest
|
gobha/armature:latest
|
||||||
|
|
||||||
# SQLite (evaluation only)
|
# SQLite (evaluation only)
|
||||||
docker run -p 8080:80 \
|
docker run -p 8080:80 \
|
||||||
-e DB_DRIVER=sqlite \
|
-e DB_DRIVER=sqlite \
|
||||||
-v armature-data:/data \
|
-v armature-data:/data \
|
||||||
ghcr.io/armature/armature:latest
|
gobha/armature:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Storage
|
### Storage
|
||||||
|
|||||||
196
docs/EXTENSION-CSS.md
Normal file
196
docs/EXTENSION-CSS.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# Extension CSS Contract
|
||||||
|
|
||||||
|
> **Version**: v0.6.13 — Responsive & Spacing
|
||||||
|
|
||||||
|
This document defines the CSS contract between the Armature kernel and extension
|
||||||
|
packages. Extensions **must** follow these rules; the kernel guarantees the listed
|
||||||
|
classes and variables are stable public API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Naming Rule
|
||||||
|
|
||||||
|
All class selectors in extension CSS (`packages/{slug}/css/main.css`) must start
|
||||||
|
with `.ext-{slug}-`. The `{slug}` is the package directory name.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Good */
|
||||||
|
.ext-my-app-sidebar { ... }
|
||||||
|
.ext-my-app-card { ... }
|
||||||
|
|
||||||
|
/* Bad — will be rejected by the linter */
|
||||||
|
.sidebar { ... }
|
||||||
|
.my-sidebar { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Compound selectors**: Descendant classes scoped under your `.ext-{slug}` root
|
||||||
|
are allowed to reference kernel classes or state modifiers:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Allowed — kernel class scoped under extension namespace */
|
||||||
|
.ext-my-app .sw-btn { margin-top: 8px; }
|
||||||
|
|
||||||
|
/* Allowed — state modifier on an extension element */
|
||||||
|
.ext-my-app-item.active { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `bash scripts/lint-package-css.sh` to validate. The linter checks that the
|
||||||
|
**first** class selector in every rule starts with `.ext-{slug}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stable Kernel Classes
|
||||||
|
|
||||||
|
Extensions may reference these classes in compound selectors. They are part of
|
||||||
|
the public API and will not change without a major version bump.
|
||||||
|
|
||||||
|
### Components (from `sw-primitives.css`)
|
||||||
|
|
||||||
|
| Class pattern | Component |
|
||||||
|
|---------------|-----------|
|
||||||
|
| `.sw-btn`, `.sw-btn--{variant}`, `.sw-btn--{size}` | Buttons |
|
||||||
|
| `.sw-input` | Text inputs |
|
||||||
|
| `.sw-field`, `.sw-field__label`, `.sw-field__hint` | Form fields |
|
||||||
|
| `.sw-dialog`, `.sw-dialog__header`, `.sw-dialog__body`, `.sw-dialog__footer` | Dialogs |
|
||||||
|
| `.sw-toast`, `.sw-toast-container` | Toast notifications |
|
||||||
|
| `.sw-menu`, `.sw-menu-item` | Context menus |
|
||||||
|
| `.sw-tabs`, `.sw-tab-btn` | Tab strips |
|
||||||
|
| `.sw-dropdown` | Custom dropdowns |
|
||||||
|
| `.sw-spinner` | Loading spinners |
|
||||||
|
| `.sw-avatar` | User avatars |
|
||||||
|
| `.sw-drawer` | Slide-out drawers |
|
||||||
|
| `.sw-banner` | Banner bars |
|
||||||
|
| `.sw-tooltip` | Tooltips |
|
||||||
|
|
||||||
|
### Extension Mount
|
||||||
|
|
||||||
|
The extension surface container has a `data-ext` attribute set to the package
|
||||||
|
slug. Use this for scoping if needed:
|
||||||
|
|
||||||
|
```css
|
||||||
|
[data-ext="my-app"] .ext-my-app-sidebar { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stable CSS Variables
|
||||||
|
|
||||||
|
All variables from `variables.css` are public API. Extensions should use these
|
||||||
|
instead of hardcoded colors to respect the user's theme.
|
||||||
|
|
||||||
|
### Colors
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `--bg` | Page background |
|
||||||
|
| `--bg-secondary` | Secondary/darker background |
|
||||||
|
| `--bg-elevated` | Elevated surface background |
|
||||||
|
| `--bg-raised` | Raised card background |
|
||||||
|
| `--bg-surface` | Surface-level background |
|
||||||
|
| `--bg-hover` | Hover state background |
|
||||||
|
| `--bg-active` | Active/pressed state background |
|
||||||
|
| `--bg-code` | Code block background |
|
||||||
|
| `--text` | Primary text color |
|
||||||
|
| `--text-2` | Secondary text color |
|
||||||
|
| `--text-3` | Tertiary/muted text color |
|
||||||
|
| `--text-on-color` | Text on colored backgrounds |
|
||||||
|
| `--accent` | Primary accent color |
|
||||||
|
| `--accent-dim` | Dimmed accent for backgrounds |
|
||||||
|
| `--accent-hover` | Accent hover state |
|
||||||
|
| `--accent-light` | Light accent variant |
|
||||||
|
| `--border` | Default border color |
|
||||||
|
| `--border-light` | Light border variant |
|
||||||
|
| `--border-elevated` | Border for elevated surfaces |
|
||||||
|
| `--danger` | Error/destructive color |
|
||||||
|
| `--danger-dim` | Dimmed danger background |
|
||||||
|
| `--danger-light` | Light danger variant |
|
||||||
|
| `--success` | Success/positive color |
|
||||||
|
| `--success-dim` | Dimmed success background |
|
||||||
|
| `--success-light` | Light success variant |
|
||||||
|
| `--warning` | Warning/caution color |
|
||||||
|
| `--warning-dim` | Dimmed warning background |
|
||||||
|
| `--warning-light` | Light warning variant |
|
||||||
|
| `--purple` | Purple accent |
|
||||||
|
| `--purple-dim` | Dimmed purple background |
|
||||||
|
|
||||||
|
### Spacing
|
||||||
|
|
||||||
|
Use spacing tokens instead of hardcoded values for padding, margin, and gap.
|
||||||
|
For sub-4px values (1px, 2px, 3px) used in borders and fine detail, hardcoded
|
||||||
|
values are acceptable.
|
||||||
|
|
||||||
|
| Variable | Value | Computed |
|
||||||
|
|----------|-------|---------|
|
||||||
|
| `--sp-1` | `0.25rem` | 4px |
|
||||||
|
| `--sp-1h` | `0.375rem` | 6px |
|
||||||
|
| `--sp-2` | `0.5rem` | 8px |
|
||||||
|
| `--sp-2h` | `0.625rem` | 10px |
|
||||||
|
| `--sp-3` | `0.75rem` | 12px |
|
||||||
|
| `--sp-4` | `1rem` | 16px |
|
||||||
|
| `--sp-5` | `1.25rem` | 20px |
|
||||||
|
| `--sp-6` | `1.5rem` | 24px |
|
||||||
|
| `--sp-8` | `2rem` | 32px |
|
||||||
|
| `--sp-10` | `2.5rem` | 40px |
|
||||||
|
| `--sp-12` | `3rem` | 48px |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.ext-my-app-card {
|
||||||
|
padding: var(--sp-3) var(--sp-4); /* 12px 16px */
|
||||||
|
gap: var(--sp-2); /* 8px */
|
||||||
|
margin-bottom: var(--sp-4); /* 16px */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout & Typography
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|----------|---------|
|
||||||
|
| `--font` | Primary font family |
|
||||||
|
| `--mono` | Monospace font family |
|
||||||
|
| `--radius-sm` | Small border-radius (4px) — badges, inline controls |
|
||||||
|
| `--radius` | Default border-radius (8px) — buttons, inputs, cards |
|
||||||
|
| `--radius-lg` | Large border-radius (12px) — modals, dialogs, large cards |
|
||||||
|
| `--transition` | Default transition timing |
|
||||||
|
| `--shadow-lg` | Large elevation shadow |
|
||||||
|
| `--overlay` | Modal overlay color |
|
||||||
|
| `--glass` | Glassmorphism backdrop |
|
||||||
|
| `--input-bg` | Form input background |
|
||||||
|
| `--sidebar-w` | Sidebar width |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsive Breakpoints
|
||||||
|
|
||||||
|
The kernel uses these standard breakpoints. Extensions should use the same
|
||||||
|
values for consistency.
|
||||||
|
|
||||||
|
| Name | Media Query | Use Case |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| Mobile | `@media (max-width: 768px)` | Phone-sized, single column |
|
||||||
|
| Tablet | `@media (max-width: 1024px)` | Tablet/small laptop, narrower sidebars |
|
||||||
|
| Desktop | Default (no query) | Full layout |
|
||||||
|
|
||||||
|
CSS custom properties cannot be used in `@media` queries — use the pixel
|
||||||
|
values directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Is Internal
|
||||||
|
|
||||||
|
Everything not listed above is **internal kernel CSS** and may change between
|
||||||
|
minor versions. Extensions must not depend on:
|
||||||
|
|
||||||
|
- Kernel layout classes (`.admin-*`, `.surface-*`, `.sidebar`, etc.)
|
||||||
|
- Kernel CSS file load order
|
||||||
|
- Specific HTML structure of the shell or topbar
|
||||||
|
- Undocumented CSS variables
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
The linter script `scripts/lint-package-css.sh` runs against all
|
||||||
|
`packages/*/css/main.css` files. It exits non-zero if any rule's first class
|
||||||
|
selector does not start with `.ext-{slug}`.
|
||||||
227
docs/USABILITY-SURVEY.md
Normal file
227
docs/USABILITY-SURVEY.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# Armature Usability Survey — Automated Checklist
|
||||||
|
|
||||||
|
> **Purpose**: Machine-auditable quality gate for the Armature UI.
|
||||||
|
> Run all scripts first, then walk each section. A FAIL in any section blocks release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Run these scripts from the project root and save their output:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/generate-ui-inventory.sh > ui-inventory.json
|
||||||
|
bash scripts/check-contrast.sh > contrast-report.txt
|
||||||
|
bash scripts/generate-coverage-matrix.sh > coverage-matrix.md
|
||||||
|
bash scripts/audit-touch-targets.sh > touch-targets-report.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 1: Viewport Correctness
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- No CSS file uses `100vh` (should be `100%` or `100dvh`)
|
||||||
|
- `.sw-shell` uses `height: 100%`, not `100vh`
|
||||||
|
- All extension surfaces use `height: 100%`
|
||||||
|
- No `transform: scale()` for zoom (should use CSS `zoom`)
|
||||||
|
|
||||||
|
**Files to inspect:**
|
||||||
|
- `src/css/sw-shell.css`
|
||||||
|
- `src/css/layout.css`
|
||||||
|
- `packages/*/css/main.css`
|
||||||
|
|
||||||
|
**How to check:**
|
||||||
|
```bash
|
||||||
|
grep -rn '100vh' src/css/ packages/*/css/ --include='*.css'
|
||||||
|
grep -rn 'transform.*scale' src/css/ packages/*/css/ --include='*.css'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** PASS if zero matches. FAIL if any `100vh` or `transform: scale()` for layout sizing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 2: Banner Integration
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- Banners are in-flow (no `position: fixed` on banner elements)
|
||||||
|
- `--banner-top-height` and `--banner-bottom-height` are defined in `:root`
|
||||||
|
- Shell layout accounts for banner height via CSS variables, not hardcoded px
|
||||||
|
- No surface hardcodes `28px` or other banner height values
|
||||||
|
|
||||||
|
**Files to inspect:**
|
||||||
|
- `src/css/sw-shell.css`
|
||||||
|
- `src/css/variables.css` (`:root` block)
|
||||||
|
- `src/js/sw/shell/app-shell.js`
|
||||||
|
|
||||||
|
**How to check:**
|
||||||
|
```bash
|
||||||
|
grep -n 'position.*fixed' src/css/sw-shell.css | grep -i banner
|
||||||
|
grep -n '28px' src/css/ -r --include='*.css'
|
||||||
|
grep -n 'banner-top-height\|banner-bottom-height' src/css/variables.css
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** PASS if banners are in-flow and height is variable-driven. FAIL if fixed positioning or hardcoded heights.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 3: Responsive Behavior
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- Kernel CSS uses `768px` (mobile) and `1024px` (tablet) breakpoints
|
||||||
|
- No hardcoded widths that break below 768px (except intentional min-widths on dialogs)
|
||||||
|
- Sidebar collapses on mobile
|
||||||
|
- Extension surfaces adapt to narrow viewports
|
||||||
|
|
||||||
|
**Files to inspect:**
|
||||||
|
- `src/css/layout.css`
|
||||||
|
- `src/css/surfaces.css`
|
||||||
|
- `packages/*/css/main.css`
|
||||||
|
|
||||||
|
**How to check:**
|
||||||
|
```bash
|
||||||
|
# Verify breakpoints used
|
||||||
|
grep -rn '@media.*max-width' src/css/ --include='*.css' | grep -v '768\|1024'
|
||||||
|
# Check for hardcoded widths
|
||||||
|
grep -rn 'width:.*[0-9]\+px' src/css/layout.css | grep -v 'max-width\|min-width\|--'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** PASS if only 768px and 1024px breakpoints. WARN if other breakpoints exist but are justified. FAIL if layout breaks below 768px.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 4: Styling Consistency
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- All spacing uses `--sp-*` tokens (no raw px for padding/margin/gap > 3px)
|
||||||
|
- All `border-radius` uses `--radius-sm`, `--radius`, or `--radius-lg`
|
||||||
|
- All `font-family` uses `var(--font)` or `var(--mono)`
|
||||||
|
- No external font CDN imports (`@import url(` or Google Fonts references)
|
||||||
|
- No stale fallback colors (`#b38a4e` or other non-token hex in property values)
|
||||||
|
|
||||||
|
**Files to inspect:**
|
||||||
|
- All `src/css/*.css`
|
||||||
|
- `packages/*/css/main.css`
|
||||||
|
|
||||||
|
**How to check:**
|
||||||
|
```bash
|
||||||
|
# Raw px spacing (padding/margin/gap > 3px, not inside var())
|
||||||
|
grep -rnE '(padding|margin|gap):\s*[0-9]+(px|rem)' src/css/ packages/*/css/ --include='*.css' | grep -v 'var(--' | grep -v '0px\|1px\|2px\|3px'
|
||||||
|
# Raw border-radius
|
||||||
|
grep -rn 'border-radius:' src/css/ packages/*/css/ --include='*.css' | grep -v 'var(--radius'
|
||||||
|
# External fonts
|
||||||
|
grep -rn '@import url\|fonts.googleapis' src/css/ --include='*.css'
|
||||||
|
# Stale fallback gold color
|
||||||
|
grep -rn '#b38a4e' src/css/ packages/*/css/ --include='*.css'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** PASS if zero non-token values (excluding reset/keyframe contexts). WARN for 1-3 edge cases with justification. FAIL for systematic violations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 5: Accessibility — Contrast
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- `contrast-report.txt` shows all PASS for normal text (4.5:1 ratio)
|
||||||
|
- No FAIL results in either dark or light theme
|
||||||
|
|
||||||
|
**Files to inspect:**
|
||||||
|
- `contrast-report.txt` (generated above)
|
||||||
|
|
||||||
|
**How to check:**
|
||||||
|
```bash
|
||||||
|
grep 'FAIL' contrast-report.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** PASS if zero FAIL lines. FAIL if any contrast violation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 6: Accessibility — Touch Targets
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- `touch-targets-report.txt` shows zero violations
|
||||||
|
- All close buttons have `min-width: 44px; min-height: 44px` in `@media (max-width: 768px)`
|
||||||
|
- Menu items have `min-height: 44px` on mobile (already done in `sw-primitives.css`)
|
||||||
|
|
||||||
|
**Files to inspect:**
|
||||||
|
- `touch-targets-report.txt` (generated above)
|
||||||
|
- `src/css/sw-primitives.css` — close button rules
|
||||||
|
|
||||||
|
**How to check:**
|
||||||
|
```bash
|
||||||
|
grep 'FAIL\|MISSING' touch-targets-report.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** PASS if zero violations. FAIL if any close button lacks mobile touch target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 7: Accessibility — Focus Indicators
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- All interactive primitives have `:focus-visible` styles
|
||||||
|
- No `outline: none` without a replacement focus indicator
|
||||||
|
- Focus ring is visible on both dark and light themes
|
||||||
|
|
||||||
|
**Files to inspect:**
|
||||||
|
- `src/css/sw-primitives.css`
|
||||||
|
- `src/css/primitives.css`
|
||||||
|
- `src/css/variables.css`
|
||||||
|
|
||||||
|
**How to check:**
|
||||||
|
```bash
|
||||||
|
# Check for focus-visible on key primitives
|
||||||
|
for cls in sw-btn sw-input sw-dropdown__trigger sw-menu__item sw-tabs__tab; do
|
||||||
|
echo -n "$cls: "
|
||||||
|
grep -c "\.${cls}.*:focus-visible\|\.${cls}:focus-visible" src/css/sw-primitives.css src/css/primitives.css 2>/dev/null || echo "0"
|
||||||
|
done
|
||||||
|
# Check for outline:none without replacement
|
||||||
|
grep -n 'outline.*none\|outline.*0' src/css/*.css | grep -v 'focus-visible\|focus-within'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** PASS if all 5 key primitives have `:focus-visible`. WARN if outline:none exists with adequate replacement. FAIL if missing focus indicators.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 8: Component Uniformity
|
||||||
|
|
||||||
|
**Pass criteria:**
|
||||||
|
- `coverage-matrix.md` shows no deprecated component usage (no ⚠ in the deprecated row)
|
||||||
|
- All surfaces use `sw-*` primitives, not old `.btn-*`, `.toast`, `.popup-menu`
|
||||||
|
|
||||||
|
**Files to inspect:**
|
||||||
|
- `coverage-matrix.md` (generated above)
|
||||||
|
|
||||||
|
**How to check:**
|
||||||
|
```bash
|
||||||
|
grep '⚠' coverage-matrix.md
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:** PASS if zero ⚠ markers. FAIL if any deprecated component still in use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scoring
|
||||||
|
|
||||||
|
| Section | Weight | Result |
|
||||||
|
|---------|--------|--------|
|
||||||
|
| 1. Viewport Correctness | Required | |
|
||||||
|
| 2. Banner Integration | Required | |
|
||||||
|
| 3. Responsive Behavior | Required | |
|
||||||
|
| 4. Styling Consistency | Required | |
|
||||||
|
| 5. Contrast | Required | |
|
||||||
|
| 6. Touch Targets | Required | |
|
||||||
|
| 7. Focus Indicators | Required | |
|
||||||
|
| 8. Component Uniformity | Required | |
|
||||||
|
|
||||||
|
**Overall:** PASS requires all sections PASS or WARN. Any FAIL blocks the release.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## After the Survey
|
||||||
|
|
||||||
|
1. Fix all FAIL items
|
||||||
|
2. Re-run affected scripts to confirm fixes
|
||||||
|
3. Re-run the full survey
|
||||||
|
4. Tag `v0.6.16` only after a clean survey pass
|
||||||
@@ -91,6 +91,9 @@ def create(title, type="group", participants=None, creator_id="", creator_displa
|
|||||||
cid = conv["id"]
|
cid = conv["id"]
|
||||||
|
|
||||||
# Add creator as admin participant
|
# Add creator as admin participant
|
||||||
|
# NOTE: display_name is a snapshot captured at creation time.
|
||||||
|
# DEPRECATED v0.6.15 — UI resolves display names from users table via sw.users.resolve().
|
||||||
|
# Column retained for backward compatibility; UI no longer relies on this value for display.
|
||||||
if creator_id:
|
if creator_id:
|
||||||
db.insert("participants", {
|
db.insert("participants", {
|
||||||
"conversation_id": cid,
|
"conversation_id": cid,
|
||||||
@@ -110,7 +113,7 @@ def create(title, type="group", participants=None, creator_id="", creator_displa
|
|||||||
"conversation_id": cid,
|
"conversation_id": cid,
|
||||||
"participant_id": pid,
|
"participant_id": pid,
|
||||||
"participant_type": _str(p.get("type", "user")),
|
"participant_type": _str(p.get("type", "user")),
|
||||||
"display_name": _str(p.get("display_name", "")),
|
"display_name": _str(p.get("display_name", "")), # DEPRECATED v0.6.15 — snapshot only
|
||||||
"role": _str(p.get("role", "member")),
|
"role": _str(p.get("role", "member")),
|
||||||
"joined_at": "",
|
"joined_at": "",
|
||||||
})
|
})
|
||||||
@@ -213,7 +216,7 @@ def add_participant(conversation_id, participant_id, participant_type="user", di
|
|||||||
"conversation_id": cid,
|
"conversation_id": cid,
|
||||||
"participant_id": pid,
|
"participant_id": pid,
|
||||||
"participant_type": _str(participant_type),
|
"participant_type": _str(participant_type),
|
||||||
"display_name": _str(display_name),
|
"display_name": _str(display_name), # DEPRECATED v0.6.15 — snapshot only
|
||||||
"role": _str(role),
|
"role": _str(role),
|
||||||
"joined_at": "",
|
"joined_at": "",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,29 +9,29 @@
|
|||||||
|
|
||||||
/* ── Layout ─────────────────────────────── */
|
/* ── Layout ─────────────────────────────── */
|
||||||
|
|
||||||
.chat-app {
|
.ext-chat-app {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-loading {
|
.ext-chat-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-body {
|
.ext-chat-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-main {
|
.ext-chat-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -40,16 +40,16 @@
|
|||||||
|
|
||||||
/* ── Topbar extras ──────────────────────── */
|
/* ── Topbar extras ──────────────────────── */
|
||||||
|
|
||||||
.chat-topbar__thread-title {
|
.ext-chat-topbar__thread-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-right: 8px;
|
margin-right: var(--sp-2);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sidebar ────────────────────────────── */
|
/* ── Sidebar ────────────────────────────── */
|
||||||
|
|
||||||
.chat-sidebar {
|
.ext-chat-sidebar {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
min-width: 280px;
|
min-width: 280px;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
@@ -58,76 +58,76 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__header {
|
.ext-chat-sidebar__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__title {
|
.ext-chat-sidebar__title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__list {
|
.ext-chat-sidebar__list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__empty {
|
.ext-chat-sidebar__empty {
|
||||||
padding: 24px 16px;
|
padding: var(--sp-6) var(--sp-4);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item {
|
.ext-chat-sidebar__item {
|
||||||
padding: 10px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-bottom: 1px solid var(--border-light);
|
border-bottom: 1px solid var(--border-light);
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item:hover {
|
.ext-chat-sidebar__item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item--active {
|
.ext-chat-sidebar__item--active {
|
||||||
background: var(--accent-dim);
|
background: var(--accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item-top {
|
.ext-chat-sidebar__item-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item-title {
|
.ext-chat-sidebar__item-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-right: 8px;
|
margin-right: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item-time {
|
.ext-chat-sidebar__item-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item-bottom {
|
.ext-chat-sidebar__item-bottom {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item-preview {
|
.ext-chat-sidebar__item-preview {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__badge {
|
.ext-chat-sidebar__badge {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--text-on-color);
|
color: var(--text-on-color);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -153,17 +153,17 @@
|
|||||||
|
|
||||||
/* ── Sidebar Search ────────────────────── */
|
/* ── Sidebar Search ────────────────────── */
|
||||||
|
|
||||||
.chat-sidebar__search {
|
.ext-chat-sidebar__search {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 8px 16px;
|
padding: var(--sp-2) var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border-light);
|
border-bottom: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__search-input {
|
.ext-chat-sidebar__search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
padding: 6px 28px 6px 10px;
|
padding: var(--sp-2) var(--sp-6) var(--sp-2) var(--sp-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
@@ -171,12 +171,12 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__search-input:focus {
|
.ext-chat-sidebar__search-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__search-clear {
|
.ext-chat-sidebar__search-clear {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 22px;
|
right: 22px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -186,21 +186,21 @@
|
|||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding: 0 4px;
|
padding: 0 var(--sp-1);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__search-clear:hover {
|
.ext-chat-sidebar__search-clear:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__search-results {
|
.ext-chat-sidebar__search-results {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__search-section {
|
.ext-chat-sidebar__search-section {
|
||||||
padding: 8px 16px 4px;
|
padding: var(--sp-2) var(--sp-4) var(--sp-1);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -208,13 +208,13 @@
|
|||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__search-loading {
|
.ext-chat-sidebar__search-loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 16px;
|
padding: var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-sidebar__item--search-msg .chat-sidebar__item-preview {
|
.ext-chat-sidebar__item--search-msg .ext-chat-sidebar__item-preview {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@@ -225,58 +225,58 @@
|
|||||||
|
|
||||||
/* ── Message Thread ─────────────────────── */
|
/* ── Message Thread ─────────────────────── */
|
||||||
|
|
||||||
.chat-thread {
|
.ext-chat-thread {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread--empty {
|
.ext-chat-thread--empty {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread__messages {
|
.ext-chat-thread__messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px;
|
padding: var(--sp-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread__loading {
|
.ext-chat-thread__loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 24px;
|
padding: var(--sp-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread__loading-more {
|
.ext-chat-thread__loading-more {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 8px;
|
padding: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread__load-more {
|
.ext-chat-thread__load-more {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
padding: 4px 12px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-bottom: 8px;
|
margin-bottom: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread__load-more:hover {
|
.ext-chat-thread__load-more:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-thread__typing {
|
.ext-chat-thread__typing {
|
||||||
padding: 4px 16px 8px;
|
padding: var(--sp-1) var(--sp-4) var(--sp-2);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
@@ -284,52 +284,52 @@
|
|||||||
|
|
||||||
/* ── Message Bubble ─────────────────────── */
|
/* ── Message Bubble ─────────────────────── */
|
||||||
|
|
||||||
.chat-msg {
|
.ext-chat-msg {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
padding: 4px 0;
|
padding: var(--sp-1) 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--own {
|
.ext-chat-msg--own {
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--system {
|
.ext-chat-msg--system {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--system span {
|
.ext-chat-msg--system span {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--deleted {
|
.ext-chat-msg--deleted {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--deleted em {
|
.ext-chat-msg--deleted em {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__body {
|
.ext-chat-msg__body {
|
||||||
max-width: 65%;
|
max-width: 65%;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 8px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--own .chat-msg__body {
|
.ext-chat-msg--own .ext-chat-msg__body {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--text-on-color);
|
color: var(--text-on-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__name {
|
.ext-chat-msg__name {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
@@ -337,42 +337,42 @@
|
|||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__content {
|
.ext-chat-msg__content {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__meta {
|
.ext-chat-msg__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__time {
|
.ext-chat-msg__time {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--own .chat-msg__time {
|
.ext-chat-msg--own .ext-chat-msg__time {
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__edited {
|
.ext-chat-msg__edited {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--own .chat-msg__edited {
|
.ext-chat-msg--own .ext-chat-msg__edited {
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Message Actions ────────────────────── */
|
/* ── Message Actions ────────────────────── */
|
||||||
|
|
||||||
.chat-msg__actions {
|
.ext-chat-msg__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -380,50 +380,50 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg--own .chat-msg__actions {
|
.ext-chat-msg--own .ext-chat-msg__actions {
|
||||||
right: auto;
|
right: auto;
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__action {
|
.ext-chat-msg__action {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 4px 6px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__action:hover {
|
.ext-chat-msg__action:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__action--danger:hover {
|
.ext-chat-msg__action--danger:hover {
|
||||||
background: var(--danger-bg);
|
background: var(--danger-bg);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Message Edit ───────────────────────── */
|
/* ── Message Edit ───────────────────────── */
|
||||||
|
|
||||||
.chat-msg__edit {
|
.ext-chat-msg__edit {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__edit-input {
|
.ext-chat-msg__edit-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
padding: 6px 8px;
|
padding: var(--sp-2) var(--sp-2);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -431,28 +431,28 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-msg__edit-actions {
|
.ext-chat-msg__edit-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Compose Bar ────────────────────────── */
|
/* ── Compose Bar ────────────────────────── */
|
||||||
|
|
||||||
.chat-compose {
|
.ext-chat-compose {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
padding: 12px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-compose__input {
|
.ext-chat-compose__input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
padding: 8px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
resize: none;
|
resize: none;
|
||||||
@@ -462,7 +462,7 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-compose__input:focus {
|
.ext-chat-compose__input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 2px var(--accent-dim);
|
box-shadow: 0 0 0 2px var(--accent-dim);
|
||||||
@@ -470,7 +470,7 @@
|
|||||||
|
|
||||||
/* ── Participant Sidebar ────────────────── */
|
/* ── Participant Sidebar ────────────────── */
|
||||||
|
|
||||||
.chat-participants {
|
.ext-chat-participants {
|
||||||
width: 240px;
|
width: 240px;
|
||||||
min-width: 240px;
|
min-width: 240px;
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
@@ -479,30 +479,30 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__header {
|
.ext-chat-participants__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__list {
|
.ext-chat-participants__list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 0;
|
padding: var(--sp-2) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__item {
|
.ext-chat-participants__item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
padding: 6px 16px;
|
padding: var(--sp-2) var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__name {
|
.ext-chat-participants__name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -510,14 +510,14 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__badge {
|
.ext-chat-participants__badge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-left: 4px;
|
margin-left: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__status {
|
.ext-chat-participants__status {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -525,75 +525,75 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__status--online {
|
.ext-chat-participants__status--online {
|
||||||
background: var(--success);
|
background: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__remove {
|
.ext-chat-participants__remove {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
padding: 0 4px;
|
padding: 0 var(--sp-1);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-participants__remove:hover {
|
.ext-chat-participants__remove:hover {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── New Conversation Dialog ────────────── */
|
/* ── New Conversation Dialog ────────────── */
|
||||||
|
|
||||||
.chat-new {
|
.ext-chat-new {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-new__type {
|
.ext-chat-new__type {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-new__type label {
|
.ext-chat-new__type label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-new__title {
|
.ext-chat-new__title {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
padding: 8px 10px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-new__selected {
|
.ext-chat-new__selected {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-new__chip {
|
.ext-chat-new__chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
background: var(--accent-dim);
|
background: var(--accent-dim);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 3px 8px;
|
padding: 3px var(--sp-2);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-new__chip button {
|
.ext-chat-new__chip button {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -607,10 +607,10 @@
|
|||||||
/* Allow the autocomplete dropdown to overflow the dialog body.
|
/* Allow the autocomplete dropdown to overflow the dialog body.
|
||||||
Applies to both New Conversation and Add Participant dialogs. */
|
Applies to both New Conversation and Add Participant dialogs. */
|
||||||
|
|
||||||
.sw-dialog__body:has(.sw-user-picker) {
|
[data-ext="chat"] .sw-dialog__body:has(.sw-user-picker) {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-dialog:has(.sw-user-picker) {
|
[data-ext="chat"] .sw-dialog:has(.sw-user-picker) {
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,60 +123,60 @@
|
|||||||
var sMsgs = showSearch ? (searchResults.messages || []) : [];
|
var sMsgs = showSearch ? (searchResults.messages || []) : [];
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-sidebar">
|
<div class="ext-chat-sidebar">
|
||||||
<div class="chat-sidebar__header">
|
<div class="ext-chat-sidebar__header">
|
||||||
<span class="chat-sidebar__title">Conversations</span>
|
<span class="ext-chat-sidebar__title">Conversations</span>
|
||||||
<${Button} size="sm" onClick=${onNew}>New<//>
|
<${Button} size="sm" onClick=${onNew}>New<//>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-sidebar__search">
|
<div class="ext-chat-sidebar__search">
|
||||||
<input class="chat-sidebar__search-input"
|
<input class="ext-chat-sidebar__search-input"
|
||||||
type="text"
|
type="text"
|
||||||
value=${searchQuery}
|
value=${searchQuery}
|
||||||
placeholder="Search\u2026"
|
placeholder="Search\u2026"
|
||||||
onInput=${handleSearchInput} />
|
onInput=${handleSearchInput} />
|
||||||
${searchQuery && html`
|
${searchQuery && html`
|
||||||
<button class="chat-sidebar__search-clear" onClick=${clearSearch}>\u00d7</button>`}
|
<button class="ext-chat-sidebar__search-clear" onClick=${clearSearch}>\u00d7</button>`}
|
||||||
</div>
|
</div>
|
||||||
${showSearch ? html`
|
${showSearch ? html`
|
||||||
<div class="chat-sidebar__search-results">
|
<div class="ext-chat-sidebar__search-results">
|
||||||
${searching && html`<div class="chat-sidebar__search-loading"><${Spinner} size="sm" /></div>`}
|
${searching && html`<div class="ext-chat-sidebar__search-loading"><${Spinner} size="sm" /></div>`}
|
||||||
${!searching && sConvs.length === 0 && sMsgs.length === 0 && html`
|
${!searching && sConvs.length === 0 && sMsgs.length === 0 && html`
|
||||||
<div class="chat-sidebar__empty">No results</div>`}
|
<div class="ext-chat-sidebar__empty">No results</div>`}
|
||||||
${sConvs.length > 0 && html`
|
${sConvs.length > 0 && html`
|
||||||
<div class="chat-sidebar__search-section">Conversations</div>
|
<div class="ext-chat-sidebar__search-section">Conversations</div>
|
||||||
${sConvs.map(c => html`
|
${sConvs.map(c => html`
|
||||||
<div key=${c.id} class="chat-sidebar__item" onClick=${() => selectFromSearch(c.id)}>
|
<div key=${c.id} class="ext-chat-sidebar__item" onClick=${() => selectFromSearch(c.id)}>
|
||||||
<div class="chat-sidebar__item-top">
|
<div class="ext-chat-sidebar__item-top">
|
||||||
<span class="chat-sidebar__item-title">${c.title || 'Untitled'}</span>
|
<span class="ext-chat-sidebar__item-title">${c.title || 'Untitled'}</span>
|
||||||
<span class="chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
|
<span class="ext-chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>`)}`}
|
</div>`)}`}
|
||||||
${sMsgs.length > 0 && html`
|
${sMsgs.length > 0 && html`
|
||||||
<div class="chat-sidebar__search-section">Messages</div>
|
<div class="ext-chat-sidebar__search-section">Messages</div>
|
||||||
${sMsgs.map(m => html`
|
${sMsgs.map(m => html`
|
||||||
<div key=${m.id} class="chat-sidebar__item chat-sidebar__item--search-msg" onClick=${() => selectFromSearch(m.conversation_id)}>
|
<div key=${m.id} class="ext-chat-sidebar__item ext-chat-sidebar__item--search-msg" onClick=${() => selectFromSearch(m.conversation_id)}>
|
||||||
<div class="chat-sidebar__item-top">
|
<div class="ext-chat-sidebar__item-top">
|
||||||
<span class="chat-sidebar__item-preview">${truncate(m.content, 80)}</span>
|
<span class="ext-chat-sidebar__item-preview">${truncate(m.content, 80)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-sidebar__item-bottom">
|
<div class="ext-chat-sidebar__item-bottom">
|
||||||
<span class="chat-sidebar__item-time">${timeAgo(m.created_at)}</span>
|
<span class="ext-chat-sidebar__item-time">${timeAgo(m.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>`)}`}
|
</div>`)}`}
|
||||||
</div>
|
</div>
|
||||||
` : html`
|
` : html`
|
||||||
<div class="chat-sidebar__list">
|
<div class="ext-chat-sidebar__list">
|
||||||
${conversations.length === 0 && html`
|
${conversations.length === 0 && html`
|
||||||
<div class="chat-sidebar__empty">No conversations yet</div>`}
|
<div class="ext-chat-sidebar__empty">No conversations yet</div>`}
|
||||||
${conversations.map(c => html`
|
${conversations.map(c => html`
|
||||||
<div key=${c.id}
|
<div key=${c.id}
|
||||||
class=${'chat-sidebar__item' + (selected === c.id ? ' chat-sidebar__item--active' : '')}
|
class=${'ext-chat-sidebar__item' + (selected === c.id ? ' ext-chat-sidebar__item--active' : '')}
|
||||||
onClick=${() => onSelect(c.id)}>
|
onClick=${() => onSelect(c.id)}>
|
||||||
<div class="chat-sidebar__item-top">
|
<div class="ext-chat-sidebar__item-top">
|
||||||
<span class="chat-sidebar__item-title">${c.title || 'Untitled'}</span>
|
<span class="ext-chat-sidebar__item-title">${c.title || 'Untitled'}</span>
|
||||||
<span class="chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
|
<span class="ext-chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-sidebar__item-bottom">
|
<div class="ext-chat-sidebar__item-bottom">
|
||||||
<span class="chat-sidebar__item-preview">
|
<span class="ext-chat-sidebar__item-preview">
|
||||||
${c.last_message
|
${c.last_message
|
||||||
? truncate(c.last_message.content_type === 'system'
|
? truncate(c.last_message.content_type === 'system'
|
||||||
? '\u2022 ' + c.last_message.content
|
? '\u2022 ' + c.last_message.content
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
: 'No messages yet'}
|
: 'No messages yet'}
|
||||||
</span>
|
</span>
|
||||||
${(unread[c.id] || 0) > 0 && html`
|
${(unread[c.id] || 0) > 0 && html`
|
||||||
<span class="chat-sidebar__badge">${unread[c.id]}</span>`}
|
<span class="ext-chat-sidebar__badge">${unread[c.id]}</span>`}
|
||||||
</div>
|
</div>
|
||||||
</div>`)}
|
</div>`)}
|
||||||
</div>
|
</div>
|
||||||
@@ -204,14 +204,14 @@
|
|||||||
|
|
||||||
if (msg._deleted) {
|
if (msg._deleted) {
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-msg chat-msg--deleted">
|
<div class="ext-chat-msg ext-chat-msg--deleted">
|
||||||
<em>This message was deleted</em>
|
<em>This message was deleted</em>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.content_type === 'system') {
|
if (msg.content_type === 'system') {
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-msg chat-msg--system">
|
<div class="ext-chat-msg ext-chat-msg--system">
|
||||||
<span>${msg.content}</span>
|
<span>${msg.content}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -239,37 +239,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class=${'chat-msg' + (isOwn ? ' chat-msg--own' : '')}
|
<div class=${'ext-chat-msg' + (isOwn ? ' ext-chat-msg--own' : '')}
|
||||||
onMouseEnter=${() => setHover(true)}
|
onMouseEnter=${() => setHover(true)}
|
||||||
onMouseLeave=${() => setHover(false)}>
|
onMouseLeave=${() => setHover(false)}>
|
||||||
${!isOwn && html`
|
${!isOwn && html`
|
||||||
<${Avatar} name=${msg._display_name || msg.participant_id} size="sm" />`}
|
<${Avatar} name=${msg._display_name || 'Unknown'} size="sm" />`}
|
||||||
<div class="chat-msg__body">
|
<div class="ext-chat-msg__body">
|
||||||
${!isOwn && html`<span class="chat-msg__name">${msg._display_name || msg.participant_id}</span>`}
|
${!isOwn && html`<span class="ext-chat-msg__name">${msg._display_name || 'Unknown'}</span>`}
|
||||||
${editing ? html`
|
${editing ? html`
|
||||||
<div class="chat-msg__edit">
|
<div class="ext-chat-msg__edit">
|
||||||
<textarea class="chat-msg__edit-input"
|
<textarea class="ext-chat-msg__edit-input"
|
||||||
value=${editText}
|
value=${editText}
|
||||||
onInput=${e => setEditText(e.target.value)}
|
onInput=${e => setEditText(e.target.value)}
|
||||||
onKeyDown=${onEditKeyDown}
|
onKeyDown=${onEditKeyDown}
|
||||||
rows="2" />
|
rows="2" />
|
||||||
<div class="chat-msg__edit-actions">
|
<div class="ext-chat-msg__edit-actions">
|
||||||
<${Button} size="sm" variant="secondary" onClick=${cancelEdit}>Cancel<//>
|
<${Button} size="sm" variant="secondary" onClick=${cancelEdit}>Cancel<//>
|
||||||
<${Button} size="sm" onClick=${saveEdit}>Save<//>
|
<${Button} size="sm" onClick=${saveEdit}>Save<//>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
` : msg.content_type === 'markdown' && sw?.markdown?.ready ? html`
|
` : msg.content_type === 'markdown' && sw?.markdown?.ready ? html`
|
||||||
<div class="chat-msg__content" dangerouslySetInnerHTML=${{ __html: sw.markdown.renderSync(msg.content, { sanitize: true }) }} />` : html`
|
<div class="ext-chat-msg__content" dangerouslySetInnerHTML=${{ __html: sw.markdown.renderSync(msg.content, { sanitize: true }) }} />` : html`
|
||||||
<div class="chat-msg__content">${msg.content}</div>`}
|
<div class="ext-chat-msg__content">${msg.content}</div>`}
|
||||||
<div class="chat-msg__meta">
|
<div class="ext-chat-msg__meta">
|
||||||
<span class="chat-msg__time">${timeAgo(msg.created_at)}</span>
|
<span class="ext-chat-msg__time">${timeAgo(msg.created_at)}</span>
|
||||||
${msg.edited_at && html`<span class="chat-msg__edited">(edited)</span>`}
|
${msg.edited_at && html`<span class="ext-chat-msg__edited">(edited)</span>`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${hover && isOwn && !editing && html`
|
${hover && isOwn && !editing && html`
|
||||||
<div class="chat-msg__actions">
|
<div class="ext-chat-msg__actions">
|
||||||
<button class="chat-msg__action" onClick=${startEdit} title="Edit">✎</button>
|
<button class="ext-chat-msg__action" onClick=${startEdit} title="Edit">✎</button>
|
||||||
<button class="chat-msg__action chat-msg__action--danger" onClick=${() => onDelete(msg.id)} title="Delete">🗑</button>
|
<button class="ext-chat-msg__action ext-chat-msg__action--danger" onClick=${() => onDelete(msg.id)} title="Delete">🗑</button>
|
||||||
</div>`}
|
</div>`}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -285,20 +285,32 @@
|
|||||||
var [hasMore, setHasMore] = useState(false);
|
var [hasMore, setHasMore] = useState(false);
|
||||||
var [nextCursor, setNextCursor] = useState('');
|
var [nextCursor, setNextCursor] = useState('');
|
||||||
var [typingUsers, setTypingUsers] = useState({});
|
var [typingUsers, setTypingUsers] = useState({});
|
||||||
|
var [resolvedNames, setResolvedNames] = useState({});
|
||||||
var bottomRef = useRef(null);
|
var bottomRef = useRef(null);
|
||||||
var listRef = useRef(null);
|
var listRef = useRef(null);
|
||||||
var userId = currentUserId();
|
var userId = currentUserId();
|
||||||
|
|
||||||
// Build participant lookup
|
// Resolve participant display names from users table (not snapshot)
|
||||||
|
useEffect(() => {
|
||||||
|
var ids = (participants || []).map(p => p.participant_id).filter(Boolean);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
sw.users.resolveMany(ids).then(map => {
|
||||||
|
var names = {};
|
||||||
|
map.forEach((user, id) => { names[id] = sw.users.displayName(user); });
|
||||||
|
setResolvedNames(names);
|
||||||
|
});
|
||||||
|
}, [participants]);
|
||||||
|
|
||||||
|
// Build participant lookup from resolved names
|
||||||
var partMap = useMemo(() => {
|
var partMap = useMemo(() => {
|
||||||
var m = {};
|
var m = {};
|
||||||
(participants || []).forEach(p => { m[p.participant_id] = p.display_name || p.participant_id; });
|
(participants || []).forEach(p => { m[p.participant_id] = resolvedNames[p.participant_id] || p.display_name || 'Unknown'; });
|
||||||
return m;
|
return m;
|
||||||
}, [participants]);
|
}, [participants, resolvedNames]);
|
||||||
|
|
||||||
// Enrich messages with display names
|
// Enrich messages with display names
|
||||||
function enrichMessages(msgs) {
|
function enrichMessages(msgs) {
|
||||||
return msgs.map(m => ({ ...m, _display_name: partMap[m.participant_id] || m.participant_id }));
|
return msgs.map(m => ({ ...m, _display_name: partMap[m.participant_id] || 'Unknown' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load initial messages
|
// Load initial messages
|
||||||
@@ -361,7 +373,7 @@
|
|||||||
|
|
||||||
var unsubs = [
|
var unsubs = [
|
||||||
sw.realtime.subscribe(channel, 'message', (payload) => {
|
sw.realtime.subscribe(channel, 'message', (payload) => {
|
||||||
var msg = { ...payload, _display_name: partMap[payload.participant_id] || payload.participant_id };
|
var msg = { ...payload, _display_name: partMap[payload.participant_id] || 'Unknown' };
|
||||||
setMessages(prev => [...prev, msg]);
|
setMessages(prev => [...prev, msg]);
|
||||||
setTimeout(() => scrollToBottom(), 50);
|
setTimeout(() => scrollToBottom(), 50);
|
||||||
// Auto mark read if from someone else
|
// Auto mark read if from someone else
|
||||||
@@ -386,7 +398,8 @@
|
|||||||
unsubs.push(sw.realtime.subscribe(channel, 'typing', (payload) => {
|
unsubs.push(sw.realtime.subscribe(channel, 'typing', (payload) => {
|
||||||
var pid = payload.participant_id;
|
var pid = payload.participant_id;
|
||||||
if (pid === userId) return;
|
if (pid === userId) return;
|
||||||
setTypingUsers(prev => ({ ...prev, [pid]: payload.display_name || pid }));
|
var typingName = resolvedNames[pid] || payload.display_name || 'Someone';
|
||||||
|
setTypingUsers(prev => ({ ...prev, [pid]: typingName }));
|
||||||
clearTimeout(typingTimers[pid]);
|
clearTimeout(typingTimers[pid]);
|
||||||
typingTimers[pid] = setTimeout(() => {
|
typingTimers[pid] = setTimeout(() => {
|
||||||
setTypingUsers(prev => {
|
setTypingUsers(prev => {
|
||||||
@@ -442,18 +455,18 @@
|
|||||||
|
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-thread chat-thread--empty">
|
<div class="ext-chat-thread chat-thread--empty">
|
||||||
<p>Select a conversation or start a new one</p>
|
<p>Select a conversation or start a new one</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-thread">
|
<div class="ext-chat-thread">
|
||||||
<div class="chat-thread__messages" ref=${listRef}>
|
<div class="ext-chat-thread__messages" ref=${listRef}>
|
||||||
${loading && messages.length === 0 && html`<div class="chat-thread__loading"><${Spinner} /></div>`}
|
${loading && messages.length === 0 && html`<div class="ext-chat-thread__loading"><${Spinner} /></div>`}
|
||||||
${loading && messages.length > 0 && html`<div class="chat-thread__loading-more"><${Spinner} size="sm" /></div>`}
|
${loading && messages.length > 0 && html`<div class="ext-chat-thread__loading-more"><${Spinner} size="sm" /></div>`}
|
||||||
${hasMore && !loading && html`
|
${hasMore && !loading && html`
|
||||||
<button class="chat-thread__load-more" onClick=${loadMore}>
|
<button class="ext-chat-thread__load-more" onClick=${loadMore}>
|
||||||
Load older messages
|
Load older messages
|
||||||
</button>`}
|
</button>`}
|
||||||
${messages.map(m => html`
|
${messages.map(m => html`
|
||||||
@@ -466,7 +479,7 @@
|
|||||||
/>`)}
|
/>`)}
|
||||||
<div ref=${bottomRef} />
|
<div ref=${bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
${typingText && html`<div class="chat-thread__typing">${typingText}</div>`}
|
${typingText && html`<div class="ext-chat-thread__typing">${typingText}</div>`}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -533,8 +546,8 @@
|
|||||||
if (!conversationId) return null;
|
if (!conversationId) return null;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-compose">
|
<div class="ext-chat-compose">
|
||||||
<textarea class="chat-compose__input"
|
<textarea class="ext-chat-compose__input"
|
||||||
ref=${textareaRef}
|
ref=${textareaRef}
|
||||||
value=${text}
|
value=${text}
|
||||||
placeholder="Type a message\u2026"
|
placeholder="Type a message\u2026"
|
||||||
@@ -553,6 +566,18 @@
|
|||||||
function ParticipantSidebar({ conversationId, participants, onRefresh, isAdmin }) {
|
function ParticipantSidebar({ conversationId, participants, onRefresh, isAdmin }) {
|
||||||
var [addOpen, setAddOpen] = useState(false);
|
var [addOpen, setAddOpen] = useState(false);
|
||||||
var [presence, setPresence] = useState({});
|
var [presence, setPresence] = useState({});
|
||||||
|
var [resolvedNames, setResolvedNames] = useState({});
|
||||||
|
|
||||||
|
// Resolve display names from users table
|
||||||
|
useEffect(() => {
|
||||||
|
var ids = (participants || []).map(p => p.participant_id).filter(Boolean);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
sw.users.resolveMany(ids).then(map => {
|
||||||
|
var names = {};
|
||||||
|
map.forEach((user, id) => { names[id] = sw.users.displayName(user); });
|
||||||
|
setResolvedNames(names);
|
||||||
|
});
|
||||||
|
}, [participants]);
|
||||||
|
|
||||||
// Query presence
|
// Query presence
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -580,22 +605,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-participants">
|
<div class="ext-chat-participants">
|
||||||
<div class="chat-participants__header">
|
<div class="ext-chat-participants__header">
|
||||||
<span>Participants (${(participants || []).length})</span>
|
<span>Participants (${(participants || []).length})</span>
|
||||||
${isAdmin && html`<${Button} size="sm" onClick=${() => setAddOpen(true)}>Add<//>` }
|
${isAdmin && html`<${Button} size="sm" onClick=${() => setAddOpen(true)}>Add<//>` }
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-participants__list">
|
<div class="ext-chat-participants__list">
|
||||||
${(participants || []).map(p => html`
|
${(participants || []).map(p => html`
|
||||||
<div key=${p.participant_id} class="chat-participants__item">
|
<div key=${p.participant_id} class="ext-chat-participants__item">
|
||||||
<${Avatar} name=${p.display_name || p.participant_id} size="sm" />
|
<${Avatar} name=${resolvedNames[p.participant_id] || p.display_name || 'Unknown'} size="sm" />
|
||||||
<span class="chat-participants__name">
|
<span class="ext-chat-participants__name">
|
||||||
${p.display_name || p.participant_id}
|
${resolvedNames[p.participant_id] || p.display_name || 'Unknown'}
|
||||||
${p.role === 'admin' && html`<span class="chat-participants__badge">admin</span>`}
|
${p.role === 'admin' && html`<span class="ext-chat-participants__badge">admin</span>`}
|
||||||
</span>
|
</span>
|
||||||
<span class=${'chat-participants__status' + (presence[p.participant_id] ? ' chat-participants__status--online' : '')} />
|
<span class=${'chat-participants__status' + (presence[p.participant_id] ? ' ext-chat-participants__status--online' : '')} />
|
||||||
${isAdmin && p.participant_id !== currentUserId() && html`
|
${isAdmin && p.participant_id !== currentUserId() && html`
|
||||||
<button class="chat-participants__remove" onClick=${() => removeUser(p.participant_id)} title="Remove">\u00d7</button>`}
|
<button class="ext-chat-participants__remove" onClick=${() => removeUser(p.participant_id)} title="Remove">\u00d7</button>`}
|
||||||
</div>`)}
|
</div>`)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -671,8 +696,8 @@
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<${Dialog} open=${open} title="New Conversation" onClose=${() => { reset(); onClose(); }} actions=${actions}>
|
<${Dialog} open=${open} title="New Conversation" onClose=${() => { reset(); onClose(); }} actions=${actions}>
|
||||||
<div class="chat-new">
|
<div class="ext-chat-new">
|
||||||
<div class="chat-new__type">
|
<div class="ext-chat-new__type">
|
||||||
<label>
|
<label>
|
||||||
<input type="radio" name="convType" value="group"
|
<input type="radio" name="convType" value="group"
|
||||||
checked=${type === 'group'} onChange=${() => { setType('group'); setSelected([]); }} />
|
checked=${type === 'group'} onChange=${() => { setType('group'); setSelected([]); }} />
|
||||||
@@ -685,14 +710,14 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
${type === 'group' && html`
|
${type === 'group' && html`
|
||||||
<input class="chat-new__title" type="text" value=${title}
|
<input class="ext-chat-new__title" type="text" value=${title}
|
||||||
placeholder="Conversation title (optional)"
|
placeholder="Conversation title (optional)"
|
||||||
onInput=${e => setTitle(e.target.value)} />`}
|
onInput=${e => setTitle(e.target.value)} />`}
|
||||||
<${UserPicker} onSelect=${addUser} placeholder=${type === 'direct' ? 'Search for a user\u2026' : 'Add participants\u2026'} />
|
<${UserPicker} onSelect=${addUser} placeholder=${type === 'direct' ? 'Search for a user\u2026' : 'Add participants\u2026'} />
|
||||||
${selected.length > 0 && html`
|
${selected.length > 0 && html`
|
||||||
<div class="chat-new__selected">
|
<div class="ext-chat-new__selected">
|
||||||
${selected.map(u => html`
|
${selected.map(u => html`
|
||||||
<span key=${u.id} class="chat-new__chip">
|
<span key=${u.id} class="ext-chat-new__chip">
|
||||||
${u.display_name || u.username}
|
${u.display_name || u.username}
|
||||||
<button onClick=${() => removeSelected(u.id)}>\u00d7</button>
|
<button onClick=${() => removeSelected(u.id)}>\u00d7</button>
|
||||||
</span>`)}
|
</span>`)}
|
||||||
@@ -807,27 +832,27 @@
|
|||||||
var threadTitle = selectedConv ? (selectedConv.title || 'Direct Message') : '';
|
var threadTitle = selectedConv ? (selectedConv.title || 'Direct Message') : '';
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return html`<div class="chat-loading"><${Spinner} /></div>`;
|
return html`<div class="ext-chat-loading"><${Spinner} /></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="chat-app">
|
<div class="ext-chat-app">
|
||||||
<${Topbar} title="Chat">
|
<${Topbar} title="Chat">
|
||||||
${selectedId && html`
|
${selectedId && html`
|
||||||
<span class="chat-topbar__thread-title">${threadTitle}</span>
|
<span class="ext-chat-topbar__thread-title">${threadTitle}</span>
|
||||||
<${Button} size="sm" variant="secondary"
|
<${Button} size="sm" variant="secondary"
|
||||||
onClick=${() => setShowParticipants(!showParticipants)}>
|
onClick=${() => setShowParticipants(!showParticipants)}>
|
||||||
${showParticipants ? 'Hide' : 'People'}
|
${showParticipants ? 'Hide' : 'People'}
|
||||||
<//>`}
|
<//>`}
|
||||||
<//>
|
<//>
|
||||||
<div class="chat-body">
|
<div class="ext-chat-body">
|
||||||
<${ConversationList}
|
<${ConversationList}
|
||||||
selected=${selectedId}
|
selected=${selectedId}
|
||||||
onSelect=${selectConversation}
|
onSelect=${selectConversation}
|
||||||
onNew=${() => setShowNew(true)}
|
onNew=${() => setShowNew(true)}
|
||||||
conversations=${conversations}
|
conversations=${conversations}
|
||||||
unread=${unread} />
|
unread=${unread} />
|
||||||
<div class="chat-main">
|
<div class="ext-chat-main">
|
||||||
<${MessageThread}
|
<${MessageThread}
|
||||||
conversationId=${selectedId}
|
conversationId=${selectedId}
|
||||||
participants=${participants} />
|
participants=${participants} />
|
||||||
|
|||||||
@@ -6,63 +6,63 @@
|
|||||||
All SDK components style themselves.
|
All SDK components style themselves.
|
||||||
========================================== */
|
========================================== */
|
||||||
|
|
||||||
.surface-dashboard {
|
.ext-dashboard {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Topbar ──────────────────────────────── */
|
/* ── Topbar ──────────────────────────────── */
|
||||||
|
|
||||||
.dashboard-topbar {
|
.ext-dashboard-topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
padding: 0 16px;
|
padding: 0 var(--sp-4);
|
||||||
height: 44px;
|
height: 44px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-topbar-back {
|
.ext-dashboard-topbar-back {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: color 0.15s, background 0.15s;
|
transition: color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-topbar-back:hover {
|
.ext-dashboard-topbar-back:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-topbar-title {
|
.ext-dashboard-topbar-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-topbar-sep {
|
.ext-dashboard-topbar-sep {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-topbar-spacer {
|
.ext-dashboard-topbar-spacer {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Body ────────────────────────────────── */
|
/* ── Body ────────────────────────────────── */
|
||||||
|
|
||||||
.dashboard-body {
|
.ext-dashboard-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
|
|
||||||
/* ── Sidebar ─────────────────────────────── */
|
/* ── Sidebar ─────────────────────────────── */
|
||||||
|
|
||||||
.dashboard-sidebar {
|
.ext-dashboard-sidebar {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -81,34 +81,34 @@
|
|||||||
|
|
||||||
/* ── Main Content ────────────────────────── */
|
/* ── Main Content ────────────────────────── */
|
||||||
|
|
||||||
.dashboard-main {
|
.ext-dashboard-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px;
|
padding: var(--sp-5);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-greeting {
|
.ext-dashboard-greeting {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin-bottom: 4px;
|
margin-bottom: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-subtitle {
|
.ext-dashboard-subtitle {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
margin-bottom: 20px;
|
margin-bottom: var(--sp-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-filter-bar {
|
.ext-dashboard-filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
margin-bottom: 16px;
|
margin-bottom: var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-filter-label {
|
.ext-dashboard-filter-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
@@ -118,34 +118,34 @@
|
|||||||
|
|
||||||
/* ── Cards Grid ──────────────────────────── */
|
/* ── Cards Grid ──────────────────────────── */
|
||||||
|
|
||||||
.dashboard-cards {
|
.ext-dashboard-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card {
|
.ext-dashboard-card {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 16px;
|
padding: var(--sp-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card:hover {
|
.ext-dashboard-card:hover {
|
||||||
border-color: var(--border-elevated);
|
border-color: var(--border-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card-header {
|
.ext-dashboard-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card-title {
|
.ext-dashboard-card-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -155,43 +155,43 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card-meta {
|
.ext-dashboard-card-meta {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card-desc {
|
.ext-dashboard-card-desc {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-card-actions {
|
.ext-dashboard-card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 4px;
|
margin-top: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-empty {
|
.ext-dashboard-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 40px 20px;
|
padding: var(--sp-10) var(--sp-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Admin Section ───────────────────────── */
|
/* ── Admin Section ───────────────────────── */
|
||||||
|
|
||||||
.dashboard-admin-section {
|
.ext-dashboard-admin-section {
|
||||||
margin-top: 24px;
|
margin-top: var(--sp-6);
|
||||||
padding-top: 16px;
|
padding-top: var(--sp-4);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-section-title {
|
.ext-dashboard-section-title {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
if (!mount) return;
|
if (!mount) return;
|
||||||
|
|
||||||
const surface = document.createElement('div');
|
const surface = document.createElement('div');
|
||||||
surface.className = 'surface-dashboard';
|
surface.className = 'ext-dashboard';
|
||||||
surface.id = 'dashboardSurface';
|
surface.id = 'dashboardSurface';
|
||||||
mount.appendChild(surface);
|
mount.appendChild(surface);
|
||||||
|
|
||||||
@@ -41,12 +41,12 @@
|
|||||||
|
|
||||||
// ── Body ──
|
// ── Body ──
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'dashboard-body';
|
body.className = 'ext-dashboard-body';
|
||||||
surface.appendChild(body);
|
surface.appendChild(body);
|
||||||
|
|
||||||
// ── Sidebar (tabs: activity + notes) ──
|
// ── Sidebar (tabs: activity + notes) ──
|
||||||
const sidebar = document.createElement('div');
|
const sidebar = document.createElement('div');
|
||||||
sidebar.className = 'dashboard-sidebar';
|
sidebar.className = 'ext-dashboard-sidebar';
|
||||||
body.appendChild(sidebar);
|
body.appendChild(sidebar);
|
||||||
|
|
||||||
// sw.tabs — two tabs
|
// sw.tabs — two tabs
|
||||||
@@ -83,14 +83,14 @@
|
|||||||
|
|
||||||
// ── Main content area ──
|
// ── Main content area ──
|
||||||
const main = document.createElement('div');
|
const main = document.createElement('div');
|
||||||
main.className = 'dashboard-main';
|
main.className = 'ext-dashboard-main';
|
||||||
body.appendChild(main);
|
body.appendChild(main);
|
||||||
|
|
||||||
_buildMainContent(main);
|
_buildMainContent(main);
|
||||||
|
|
||||||
// ── Theme reactivity ──
|
// ── Theme reactivity ──
|
||||||
sw.theme.on('change', function (resolved) {
|
sw.theme.on('change', function (resolved) {
|
||||||
const cards = main.querySelectorAll('.dashboard-card');
|
const cards = main.querySelectorAll('.ext-dashboard-card');
|
||||||
cards.forEach(function (c) {
|
cards.forEach(function (c) {
|
||||||
c.style.borderColor = ''; // reset to CSS default for new theme
|
c.style.borderColor = ''; // reset to CSS default for new theme
|
||||||
});
|
});
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
|
|
||||||
// ── Cross-component events ──
|
// ── Cross-component events ──
|
||||||
sw.on('dashboard.filter.changed', function (payload) {
|
sw.on('dashboard.filter.changed', function (payload) {
|
||||||
_loadChannels(main.querySelector('.dashboard-cards'), payload.value);
|
_loadChannels(main.querySelector('.ext-dashboard-cards'), payload.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[DashboardPkg] Mounted');
|
console.log('[DashboardPkg] Mounted');
|
||||||
@@ -108,15 +108,15 @@
|
|||||||
|
|
||||||
function _buildTopbar() {
|
function _buildTopbar() {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'dashboard-topbar';
|
el.className = 'ext-dashboard-topbar';
|
||||||
el.innerHTML =
|
el.innerHTML =
|
||||||
'<a href="' + esc(base) + '/" class="dashboard-topbar-back" title="Back to chat">' +
|
'<a href="' + esc(base) + '/" class="ext-dashboard-topbar-back" title="Back to chat">' +
|
||||||
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>' +
|
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>' +
|
||||||
'Back' +
|
'Back' +
|
||||||
'</a>' +
|
'</a>' +
|
||||||
'<div class="dashboard-topbar-sep"></div>' +
|
'<div class="ext-dashboard-topbar-sep"></div>' +
|
||||||
'<span class="dashboard-topbar-title">Dashboard</span>' +
|
'<span class="ext-dashboard-topbar-title">Dashboard</span>' +
|
||||||
'<div class="dashboard-topbar-spacer"></div>';
|
'<div class="ext-dashboard-topbar-spacer"></div>';
|
||||||
|
|
||||||
// sw.toolbar — action buttons
|
// sw.toolbar — action buttons
|
||||||
const toolbarItems = [
|
const toolbarItems = [
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>',
|
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>',
|
||||||
title: 'Refresh',
|
title: 'Refresh',
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
const cards = document.querySelector('.dashboard-cards');
|
const cards = document.querySelector('.ext-dashboard-cards');
|
||||||
if (cards) _loadChannels(cards, _currentFilter);
|
if (cards) _loadChannels(cards, _currentFilter);
|
||||||
sw.toast('Refreshed', 'success');
|
sw.toast('Refreshed', 'success');
|
||||||
},
|
},
|
||||||
@@ -155,22 +155,22 @@
|
|||||||
// sw.user — greeting
|
// sw.user — greeting
|
||||||
const user = sw.user;
|
const user = sw.user;
|
||||||
const greeting = document.createElement('div');
|
const greeting = document.createElement('div');
|
||||||
greeting.className = 'dashboard-greeting';
|
greeting.className = 'ext-dashboard-greeting';
|
||||||
greeting.textContent = 'Welcome back' + (user ? ', ' + (user.display_name || user.username) : '');
|
greeting.textContent = 'Welcome back' + (user ? ', ' + (user.display_name || user.username || 'Unknown') : '');
|
||||||
main.appendChild(greeting);
|
main.appendChild(greeting);
|
||||||
|
|
||||||
const subtitle = document.createElement('div');
|
const subtitle = document.createElement('div');
|
||||||
subtitle.className = 'dashboard-subtitle';
|
subtitle.className = 'ext-dashboard-subtitle';
|
||||||
subtitle.textContent = 'Your recent conversations' + (sw.isAdmin ? ' \u00b7 Admin' : '');
|
subtitle.textContent = 'Your recent conversations' + (sw.isAdmin ? ' \u00b7 Admin' : '');
|
||||||
main.appendChild(subtitle);
|
main.appendChild(subtitle);
|
||||||
|
|
||||||
// ── Filter bar ──
|
// ── Filter bar ──
|
||||||
const filterBar = document.createElement('div');
|
const filterBar = document.createElement('div');
|
||||||
filterBar.className = 'dashboard-filter-bar';
|
filterBar.className = 'ext-dashboard-filter-bar';
|
||||||
main.appendChild(filterBar);
|
main.appendChild(filterBar);
|
||||||
|
|
||||||
const filterLabel = document.createElement('span');
|
const filterLabel = document.createElement('span');
|
||||||
filterLabel.className = 'dashboard-filter-label';
|
filterLabel.className = 'ext-dashboard-filter-label';
|
||||||
filterLabel.textContent = 'Filter';
|
filterLabel.textContent = 'Filter';
|
||||||
filterBar.appendChild(filterLabel);
|
filterBar.appendChild(filterLabel);
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
|
|
||||||
// ── Cards ──
|
// ── Cards ──
|
||||||
const cards = document.createElement('div');
|
const cards = document.createElement('div');
|
||||||
cards.className = 'dashboard-cards';
|
cards.className = 'ext-dashboard-cards';
|
||||||
main.appendChild(cards);
|
main.appendChild(cards);
|
||||||
|
|
||||||
_loadChannels(cards, '');
|
_loadChannels(cards, '');
|
||||||
@@ -200,16 +200,16 @@
|
|||||||
// ── Admin section (sw.isAdmin) ──
|
// ── Admin section (sw.isAdmin) ──
|
||||||
if (sw.isAdmin) {
|
if (sw.isAdmin) {
|
||||||
const adminSection = document.createElement('div');
|
const adminSection = document.createElement('div');
|
||||||
adminSection.className = 'dashboard-admin-section';
|
adminSection.className = 'ext-dashboard-admin-section';
|
||||||
main.appendChild(adminSection);
|
main.appendChild(adminSection);
|
||||||
|
|
||||||
const sectionTitle = document.createElement('div');
|
const sectionTitle = document.createElement('div');
|
||||||
sectionTitle.className = 'dashboard-section-title';
|
sectionTitle.className = 'ext-dashboard-section-title';
|
||||||
sectionTitle.textContent = 'Administration';
|
sectionTitle.textContent = 'Administration';
|
||||||
adminSection.appendChild(sectionTitle);
|
adminSection.appendChild(sectionTitle);
|
||||||
|
|
||||||
const adminCards = document.createElement('div');
|
const adminCards = document.createElement('div');
|
||||||
adminCards.className = 'dashboard-cards';
|
adminCards.className = 'ext-dashboard-cards';
|
||||||
adminSection.appendChild(adminCards);
|
adminSection.appendChild(adminCards);
|
||||||
|
|
||||||
_loadAdminCards(adminCards);
|
_loadAdminCards(adminCards);
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
|
|
||||||
async function _loadChannels(container, typeFilter) {
|
async function _loadChannels(container, typeFilter) {
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = '<div class="dashboard-empty">Loading\u2026</div>';
|
container.innerHTML = '<div class="ext-dashboard-empty">Loading\u2026</div>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// sw.api — real API call
|
// sw.api — real API call
|
||||||
@@ -231,7 +231,7 @@
|
|||||||
|
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
if (!channels.length) {
|
if (!channels.length) {
|
||||||
container.innerHTML = '<div class="dashboard-empty">No channels found</div>';
|
container.innerHTML = '<div class="ext-dashboard-empty">No channels found</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,31 +239,31 @@
|
|||||||
container.appendChild(_buildChannelCard(ch));
|
container.appendChild(_buildChannelCard(ch));
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
container.innerHTML = '<div class="dashboard-empty">Failed to load: ' + (typeof esc === 'function' ? esc(e.message) : e.message) + '</div>';
|
container.innerHTML = '<div class="ext-dashboard-empty">Failed to load: ' + (typeof esc === 'function' ? esc(e.message) : e.message) + '</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function _buildChannelCard(ch) {
|
function _buildChannelCard(ch) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'dashboard-card';
|
card.className = 'ext-dashboard-card';
|
||||||
|
|
||||||
const header = document.createElement('div');
|
const header = document.createElement('div');
|
||||||
header.className = 'dashboard-card-header';
|
header.className = 'ext-dashboard-card-header';
|
||||||
card.appendChild(header);
|
card.appendChild(header);
|
||||||
|
|
||||||
const title = document.createElement('div');
|
const title = document.createElement('div');
|
||||||
title.className = 'dashboard-card-title';
|
title.className = 'ext-dashboard-card-title';
|
||||||
title.textContent = ch.title || ch.name || 'Untitled';
|
title.textContent = ch.title || ch.name || 'Untitled';
|
||||||
header.appendChild(title);
|
header.appendChild(title);
|
||||||
|
|
||||||
const meta = document.createElement('div');
|
const meta = document.createElement('div');
|
||||||
meta.className = 'dashboard-card-meta';
|
meta.className = 'ext-dashboard-card-meta';
|
||||||
meta.textContent = ch.type || '';
|
meta.textContent = ch.type || '';
|
||||||
card.appendChild(meta);
|
card.appendChild(meta);
|
||||||
|
|
||||||
if (ch.description) {
|
if (ch.description) {
|
||||||
const desc = document.createElement('div');
|
const desc = document.createElement('div');
|
||||||
desc.className = 'dashboard-card-desc';
|
desc.className = 'ext-dashboard-card-desc';
|
||||||
desc.textContent = ch.description.slice(0, 120);
|
desc.textContent = ch.description.slice(0, 120);
|
||||||
card.appendChild(desc);
|
card.appendChild(desc);
|
||||||
}
|
}
|
||||||
@@ -271,14 +271,14 @@
|
|||||||
const updated = ch.updated_at || ch.created_at;
|
const updated = ch.updated_at || ch.created_at;
|
||||||
if (updated) {
|
if (updated) {
|
||||||
const date = document.createElement('div');
|
const date = document.createElement('div');
|
||||||
date.className = 'dashboard-card-meta';
|
date.className = 'ext-dashboard-card-meta';
|
||||||
date.textContent = new Date(updated).toLocaleDateString();
|
date.textContent = new Date(updated).toLocaleDateString();
|
||||||
card.appendChild(date);
|
card.appendChild(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Card action menu ──
|
// ── Card action menu ──
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'dashboard-card-actions';
|
actions.className = 'ext-dashboard-card-actions';
|
||||||
card.appendChild(actions);
|
card.appendChild(actions);
|
||||||
|
|
||||||
const menuBtn = document.createElement('button');
|
const menuBtn = document.createElement('button');
|
||||||
@@ -343,8 +343,8 @@
|
|||||||
(ch.description ? '<div style="font-size:12px;color:var(--text-2);margin-bottom:8px;"><strong>Description:</strong> ' + (typeof esc === 'function' ? esc(ch.description) : ch.description) + '</div>' : '') +
|
(ch.description ? '<div style="font-size:12px;color:var(--text-2);margin-bottom:8px;"><strong>Description:</strong> ' + (typeof esc === 'function' ? esc(ch.description) : ch.description) + '</div>' : '') +
|
||||||
(ch.created_at ? '<div style="font-size:12px;color:var(--text-3);margin-bottom:16px;">Created: ' + new Date(ch.created_at).toLocaleString() + '</div>' : '') +
|
(ch.created_at ? '<div style="font-size:12px;color:var(--text-3);margin-bottom:16px;">Created: ' + new Date(ch.created_at).toLocaleString() + '</div>' : '') +
|
||||||
'<div style="display:flex;justify-content:flex-end;gap:8px;">' +
|
'<div style="display:flex;justify-content:flex-end;gap:8px;">' +
|
||||||
'<button class="btn-small" id="dashDetailClose">Close</button>' +
|
'<button class="sw-btn sw-btn--secondary sw-btn--sm" id="dashDetailClose">Close</button>' +
|
||||||
'<button class="btn-small btn-primary" id="dashDetailOpen">Open Chat</button>' +
|
'<button class="sw-btn sw-btn--primary sw-btn--sm" id="dashDetailOpen">Open Chat</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
modal.appendChild(box);
|
modal.appendChild(box);
|
||||||
@@ -372,7 +372,7 @@
|
|||||||
// sw.toast — success feedback
|
// sw.toast — success feedback
|
||||||
sw.toast('Channel deleted', 'success');
|
sw.toast('Channel deleted', 'success');
|
||||||
// Refresh cards
|
// Refresh cards
|
||||||
const cards = document.querySelector('.dashboard-cards');
|
const cards = document.querySelector('.ext-dashboard-cards');
|
||||||
if (cards) _loadChannels(cards, _currentFilter);
|
if (cards) _loadChannels(cards, _currentFilter);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// sw.toast — error feedback
|
// sw.toast — error feedback
|
||||||
@@ -390,21 +390,21 @@
|
|||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
packages.forEach(function (pkg) {
|
packages.forEach(function (pkg) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'dashboard-card';
|
card.className = 'ext-dashboard-card';
|
||||||
card.innerHTML =
|
card.innerHTML =
|
||||||
'<div class="dashboard-card-header">' +
|
'<div class="ext-dashboard-card-header">' +
|
||||||
'<div class="dashboard-card-title">' + (typeof esc === 'function' ? esc(pkg.title || pkg.id) : (pkg.title || pkg.id)) + '</div>' +
|
'<div class="ext-dashboard-card-title">' + (typeof esc === 'function' ? esc(pkg.title || pkg.id) : (pkg.title || pkg.id)) + '</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="dashboard-card-meta">' + (typeof esc === 'function' ? esc(pkg.type || '') : (pkg.type || '')) + ' \u00b7 ' + (typeof esc === 'function' ? esc(pkg.tier || '') : (pkg.tier || '')) + '</div>' +
|
'<div class="ext-dashboard-card-meta">' + (typeof esc === 'function' ? esc(pkg.type || '') : (pkg.type || '')) + ' \u00b7 ' + (typeof esc === 'function' ? esc(pkg.tier || '') : (pkg.tier || '')) + '</div>' +
|
||||||
'<div class="dashboard-card-desc">' + (typeof esc === 'function' ? esc(pkg.description || '') : (pkg.description || '')) + '</div>';
|
'<div class="ext-dashboard-card-desc">' + (typeof esc === 'function' ? esc(pkg.description || '') : (pkg.description || '')) + '</div>';
|
||||||
container.appendChild(card);
|
container.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!packages.length) {
|
if (!packages.length) {
|
||||||
container.innerHTML = '<div class="dashboard-empty">No packages installed</div>';
|
container.innerHTML = '<div class="ext-dashboard-empty">No packages installed</div>';
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
container.innerHTML = '<div class="dashboard-empty">Failed to load packages</div>';
|
container.innerHTML = '<div class="ext-dashboard-empty">Failed to load packages</div>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,166 +8,166 @@
|
|||||||
|
|
||||||
/* ── Surface Shell ─────────────────────────── */
|
/* ── Surface Shell ─────────────────────────── */
|
||||||
|
|
||||||
.surface-editor {
|
.ext-editor {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg, #0e0e10);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Topbar ────────────────────────────────── */
|
/* ── Topbar ────────────────────────────────── */
|
||||||
|
|
||||||
.editor-topbar {
|
.ext-editor-topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
padding: 0 12px;
|
padding: 0 var(--sp-3);
|
||||||
height: 40px;
|
height: 40px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--bg-secondary, #151517);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
border-bottom: 1px solid var(--border);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-topbar-back {
|
.ext-editor-topbar-back {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
color: var(--text-3, #777);
|
color: var(--text-3);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
transition: color 0.15s, background 0.15s;
|
transition: color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-topbar-back:hover {
|
.ext-editor-topbar-back:hover {
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-topbar-sep {
|
.ext-editor-topbar-sep {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background: var(--border, #2a2a2e);
|
background: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-topbar-name {
|
.ext-editor-topbar-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Workspace Selector ────────────────────── */
|
/* ── Workspace Selector ────────────────────── */
|
||||||
|
|
||||||
.editor-ws-selector {
|
.ext-editor-ws-selector {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-selector-btn {
|
.ext-editor-ws-selector-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, background 0.15s;
|
transition: border-color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-selector-btn:hover {
|
.ext-editor-ws-selector-btn:hover {
|
||||||
border-color: var(--border, #2a2a2e);
|
border-color: var(--border);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-dropdown {
|
.ext-editor-ws-dropdown {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
margin-top: 4px;
|
margin-top: var(--sp-1);
|
||||||
background: var(--bg-secondary, #1a1a1e);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
padding: 4px 0;
|
padding: var(--sp-1) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-dropdown.open { display: block; }
|
.ext-editor-ws-dropdown.open { display: block; }
|
||||||
|
|
||||||
.editor-ws-list {
|
.ext-editor-ws-list {
|
||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-dropdown-item {
|
.ext-editor-ws-dropdown-item {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
padding: 8px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-dropdown-item:hover {
|
.ext-editor-ws-dropdown-item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-dropdown-item.active {
|
.ext-editor-ws-dropdown-item.active {
|
||||||
color: var(--accent, #b38a4e);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-dropdown-divider {
|
.ext-editor-ws-dropdown-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--border, #2a2a2e);
|
background: var(--border);
|
||||||
margin: 4px 0;
|
margin: var(--sp-1) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-ws-new {
|
.ext-editor-ws-new {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
color: var(--accent, #b38a4e);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-topbar-branch {
|
.ext-editor-topbar-branch {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
background: var(--purple-dim, rgba(160, 120, 255, 0.1));
|
background: var(--purple-dim);
|
||||||
padding: 2px 8px;
|
padding: 2px var(--sp-2);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-topbar-branch-text {
|
.ext-editor-topbar-branch-text {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--purple, #a078ff);
|
color: var(--purple);
|
||||||
font-family: var(--mono, 'SF Mono', monospace);
|
font-family: var(--mono, 'SF Mono', monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Body ──────────────────────────────────── */
|
/* ── Body ──────────────────────────────────── */
|
||||||
|
|
||||||
.editor-body {
|
.ext-editor-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -175,47 +175,47 @@
|
|||||||
|
|
||||||
/* ── Bootstrap (no workspace) ──────────────── */
|
/* ── Bootstrap (no workspace) ──────────────── */
|
||||||
|
|
||||||
.editor-bootstrap {
|
.ext-editor-bootstrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-bootstrap-card {
|
.ext-editor-bootstrap-card {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px;
|
padding: var(--sp-10);
|
||||||
background: var(--bg-secondary, #151517);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: var(--radius-lg);
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-bootstrap-input {
|
.ext-editor-bootstrap-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
background: var(--bg, #0e0e10);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--sp-3);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-bootstrap-input:focus {
|
.ext-editor-bootstrap-input:focus {
|
||||||
border-color: var(--accent, #b38a4e);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-bootstrap-btn {
|
.ext-editor-bootstrap-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
background: var(--accent, #b38a4e);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -223,108 +223,108 @@
|
|||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-bootstrap-btn:hover { opacity: 0.9; }
|
.ext-editor-bootstrap-btn:hover { opacity: 0.9; }
|
||||||
.editor-bootstrap-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.ext-editor-bootstrap-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
/* Workspace list in bootstrap */
|
/* Workspace list in bootstrap */
|
||||||
.editor-bootstrap-ws-item {
|
.ext-editor-bootstrap-ws-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
background: var(--bg, #0e0e10);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, background 0.15s;
|
transition: border-color 0.15s, background 0.15s;
|
||||||
margin-bottom: 6px;
|
margin-bottom: var(--sp-2);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-bootstrap-ws-item:hover {
|
.ext-editor-bootstrap-ws-item:hover {
|
||||||
border-color: var(--accent, #b38a4e);
|
border-color: var(--accent);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-bootstrap-ws-name {
|
.ext-editor-bootstrap-ws-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-bootstrap-ws-date {
|
.ext-editor-bootstrap-ws-date {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3, #777);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── FileTree overrides (in editor context) ── */
|
/* ── FileTree overrides (in editor context) ── */
|
||||||
|
|
||||||
.surface-editor .file-tree {
|
.ext-editor .file-tree {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
border-right: 1px solid var(--border, #2a2a2e);
|
border-right: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-header {
|
.ext-editor .file-tree-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
padding: 8px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-title {
|
.ext-editor .file-tree-title {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-2, #999);
|
color: var(--text-2);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-items {
|
.ext-editor .file-tree-items {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px 0;
|
padding: var(--sp-1) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-row {
|
.ext-editor .file-tree-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
padding: 3px 8px;
|
padding: 3px var(--sp-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-2, #999);
|
color: var(--text-2);
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-row:hover {
|
.ext-editor .file-tree-row:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-row.active {
|
.ext-editor .file-tree-row.active {
|
||||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
background: var(--accent-dim);
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-arrow {
|
.ext-editor .file-tree-arrow {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-3, #555);
|
color: var(--text-3);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-icon {
|
.ext-editor .file-tree-icon {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .file-tree-name {
|
.ext-editor .file-tree-name {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -332,134 +332,134 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Git status indicators */
|
/* Git status indicators */
|
||||||
.surface-editor .file-tree-row.git-modified .file-tree-name { color: var(--warning, #e5a842); }
|
.ext-editor .file-tree-row.git-modified .file-tree-name { color: var(--warning); }
|
||||||
.surface-editor .file-tree-row.git-added .file-tree-name { color: var(--success, #4caf50); }
|
.ext-editor .file-tree-row.git-added .file-tree-name { color: var(--success); }
|
||||||
.surface-editor .file-tree-row.git-untracked .file-tree-name { color: var(--text-3, #555); font-style: italic; }
|
.ext-editor .file-tree-row.git-untracked .file-tree-name { color: var(--text-3); font-style: italic; }
|
||||||
.surface-editor .file-tree-row.git-deleted .file-tree-name { color: var(--danger, #f44336); text-decoration: line-through; }
|
.ext-editor .file-tree-row.git-deleted .file-tree-name { color: var(--danger); text-decoration: line-through; }
|
||||||
|
|
||||||
/* Context menu */
|
/* Context menu */
|
||||||
.file-tree-ctx-menu {
|
.ext-editor-file-tree-ctx-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background: var(--bg-secondary, #1a1a1e);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
padding: 4px 0;
|
padding: var(--sp-1) 0;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-tree-ctx-item {
|
.ext-editor-file-tree-ctx-item {
|
||||||
padding: 6px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-tree-ctx-item:hover {
|
.ext-editor-file-tree-ctx-item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── CodeEditor overrides ──────────────────── */
|
/* ── CodeEditor overrides ──────────────────── */
|
||||||
|
|
||||||
.surface-editor .code-editor {
|
.ext-editor .code-editor {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-tabs {
|
.ext-editor .code-editor-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
background: var(--bg-secondary, #151517);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
border-bottom: 1px solid var(--border);
|
||||||
height: 32px;
|
height: 32px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-tabs::-webkit-scrollbar { height: 0; }
|
.ext-editor .code-editor-tabs::-webkit-scrollbar { height: 0; }
|
||||||
|
|
||||||
.surface-editor .code-editor-tab {
|
.ext-editor .code-editor-tab {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
padding: 0 12px;
|
padding: 0 var(--sp-3);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3, #777);
|
color: var(--text-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-right: 1px solid var(--border, #2a2a2e);
|
border-right: 1px solid var(--border);
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-tab:hover { background: var(--bg-hover); }
|
.ext-editor .code-editor-tab:hover { background: var(--bg-hover); }
|
||||||
.surface-editor .code-editor-tab.active { color: var(--text, #eee); background: var(--bg, #0e0e10); }
|
.ext-editor .code-editor-tab.active { color: var(--text); background: var(--bg); }
|
||||||
.surface-editor .code-editor-tab.modified .code-editor-tab-modified { color: var(--warning, #e5a842); }
|
.ext-editor .code-editor-tab.modified .code-editor-tab-modified { color: var(--warning); }
|
||||||
|
|
||||||
.surface-editor .code-editor-tab-icon { font-size: 12px; }
|
.ext-editor .code-editor-tab-icon { font-size: 12px; }
|
||||||
.surface-editor .code-editor-tab-modified { font-size: 10px; color: var(--text-3); }
|
.ext-editor .code-editor-tab-modified { font-size: 10px; color: var(--text-3); }
|
||||||
|
|
||||||
.surface-editor .code-editor-tab-close {
|
.ext-editor .code-editor-tab-close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-3, #555);
|
color: var(--text-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
margin-left: 4px;
|
margin-left: var(--sp-1);
|
||||||
border-radius: 2px;
|
border-radius: var(--radius-sm);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-tab-close:hover {
|
.ext-editor .code-editor-tab-close:hover {
|
||||||
background: var(--danger-dim, rgba(244, 67, 54, 0.15));
|
background: var(--danger-dim);
|
||||||
color: var(--danger, #f44336);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-content {
|
.ext-editor .code-editor-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-welcome {
|
.ext-editor .code-editor-welcome {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-cm-wrap {
|
.ext-editor .code-editor-cm-wrap {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-cm-wrap .cm-editor {
|
.ext-editor .code-editor-cm-wrap .cm-editor {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-statusbar {
|
.ext-editor .code-editor-statusbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: var(--sp-4);
|
||||||
padding: 0 12px;
|
padding: 0 var(--sp-3);
|
||||||
height: 24px;
|
height: 24px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--bg-secondary, #151517);
|
background: var(--bg-secondary);
|
||||||
border-top: 1px solid var(--border, #2a2a2e);
|
border-top: 1px solid var(--border);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3, #777);
|
color: var(--text-3);
|
||||||
font-family: var(--mono, 'SF Mono', monospace);
|
font-family: var(--mono, 'SF Mono', monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .code-editor-textarea-fallback {
|
.ext-editor .code-editor-textarea-fallback {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--bg, #0e0e10);
|
background: var(--bg);
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px;
|
padding: var(--sp-3);
|
||||||
font-family: var(--mono, 'SF Mono', monospace);
|
font-family: var(--mono, 'SF Mono', monospace);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
resize: none;
|
resize: none;
|
||||||
@@ -469,18 +469,18 @@
|
|||||||
|
|
||||||
/* ── Tabbed assist pane overrides ──────────── */
|
/* ── Tabbed assist pane overrides ──────────── */
|
||||||
|
|
||||||
.surface-editor .pane-tabbed {
|
.ext-editor .pane-tabbed {
|
||||||
border-left: 1px solid var(--border, #2a2a2e);
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ChatPane in editor tabbed pane */
|
/* ChatPane in editor tabbed pane */
|
||||||
.surface-editor .chat-pane {
|
.ext-editor .chat-pane {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Notes in editor pane */
|
/* Notes in editor pane */
|
||||||
.surface-editor .note-editor {
|
.ext-editor .ext-notes-editor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -488,43 +488,43 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .note-editor-list-view {
|
.ext-editor .ext-notes-editor-list-view {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .notes-list {
|
.ext-editor .ext-notes-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact notes toolbar for narrow pane */
|
/* Compact notes toolbar for narrow pane */
|
||||||
.surface-editor .notes-toolbar {
|
.ext-editor .ext-notes-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
padding: 6px 8px;
|
padding: var(--sp-2) var(--sp-2);
|
||||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .notes-toolbar .btn-small {
|
.ext-editor .ext-notes-toolbar .sw-btn--sm {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 3px 6px;
|
padding: 3px var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .notes-search-row {
|
.ext-editor .ext-notes-search-row {
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .notes-filter-row {
|
.ext-editor .ext-notes-filter-row {
|
||||||
padding: 2px 8px 4px;
|
padding: 2px var(--sp-2) var(--sp-1);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.surface-editor .notes-filter-select {
|
.ext-editor .ext-notes-filter-select {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
|
|
||||||
// Wrap in surface container for CSS scoping
|
// Wrap in surface container for CSS scoping
|
||||||
const surface = document.createElement('div');
|
const surface = document.createElement('div');
|
||||||
surface.className = 'surface-editor';
|
surface.className = 'ext-editor';
|
||||||
surface.id = 'editorSurface';
|
surface.id = 'editorSurface';
|
||||||
mount.appendChild(surface);
|
mount.appendChild(surface);
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
|
|
||||||
// Build body + bootstrap
|
// Build body + bootstrap
|
||||||
const body = document.createElement('div');
|
const body = document.createElement('div');
|
||||||
body.className = 'editor-body';
|
body.className = 'ext-editor-body';
|
||||||
body.id = 'editorBody';
|
body.id = 'editorBody';
|
||||||
surface.appendChild(body);
|
surface.appendChild(body);
|
||||||
|
|
||||||
@@ -113,32 +113,32 @@
|
|||||||
|
|
||||||
function _buildTopbar(wsName) {
|
function _buildTopbar(wsName) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'editor-topbar';
|
el.className = 'ext-editor-topbar';
|
||||||
el.id = 'editorTopbar';
|
el.id = 'editorTopbar';
|
||||||
el.innerHTML =
|
el.innerHTML =
|
||||||
'<a href="' + esc(base) + '/" class="editor-topbar-back" title="Back to chat">' +
|
'<a href="' + esc(base) + '/" class="ext-editor-topbar-back" title="Back to chat">' +
|
||||||
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>' +
|
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>' +
|
||||||
'Back' +
|
'Back' +
|
||||||
'</a>' +
|
'</a>' +
|
||||||
'<div class="editor-topbar-sep"></div>' +
|
'<div class="ext-editor-topbar-sep"></div>' +
|
||||||
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>' +
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>' +
|
||||||
'<div class="editor-ws-selector" id="editorWsSelector">' +
|
'<div class="ext-editor-ws-selector" id="editorWsSelector">' +
|
||||||
'<button class="editor-ws-selector-btn" id="editorWsSelectorBtn">' +
|
'<button class="ext-editor-ws-selector-btn" id="editorWsSelectorBtn">' +
|
||||||
'<span id="editorWorkspaceName">' + esc(wsName || 'Editor') + '</span>' +
|
'<span id="editorWorkspaceName">' + esc(wsName || 'Editor') + '</span>' +
|
||||||
'<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>' +
|
'<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>' +
|
||||||
'</button>' +
|
'</button>' +
|
||||||
'<div class="editor-ws-dropdown" id="editorWsDropdown">' +
|
'<div class="ext-editor-ws-dropdown" id="editorWsDropdown">' +
|
||||||
'<div id="editorWsList" class="editor-ws-list"></div>' +
|
'<div id="editorWsList" class="ext-editor-ws-list"></div>' +
|
||||||
'<div class="editor-ws-dropdown-divider"></div>' +
|
'<div class="ext-editor-ws-dropdown-divider"></div>' +
|
||||||
'<button class="editor-ws-dropdown-item editor-ws-new" id="editorWsNewBtn">' +
|
'<button class="ext-editor-ws-dropdown-item ext-editor-ws-new" id="editorWsNewBtn">' +
|
||||||
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
|
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
|
||||||
'New Workspace' +
|
'New Workspace' +
|
||||||
'</button>' +
|
'</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="editor-topbar-branch" id="editorBranchBadge" style="display:none;">' +
|
'<div class="ext-editor-topbar-branch" id="editorBranchBadge" style="display:none;">' +
|
||||||
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--purple)" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="12" cy="6" r="3"/><line x1="12" y1="9" x2="12" y2="15"/></svg>' +
|
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--purple)" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="12" cy="6" r="3"/><line x1="12" y1="9" x2="12" y2="15"/></svg>' +
|
||||||
'<span id="editorBranchName" class="editor-topbar-branch-text">main</span>' +
|
'<span id="editorBranchName" class="ext-editor-topbar-branch-text">main</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div style="flex:1;"></div>' +
|
'<div style="flex:1;"></div>' +
|
||||||
'<button class="icon-btn" id="editorRefreshBtn" title="Refresh files">' +
|
'<button class="icon-btn" id="editorRefreshBtn" title="Refresh files">' +
|
||||||
@@ -151,11 +151,11 @@
|
|||||||
|
|
||||||
function _buildBootstrap() {
|
function _buildBootstrap() {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'editor-bootstrap';
|
el.className = 'ext-editor-bootstrap';
|
||||||
el.id = 'editorBootstrap';
|
el.id = 'editorBootstrap';
|
||||||
el.style.display = 'none';
|
el.style.display = 'none';
|
||||||
el.innerHTML =
|
el.innerHTML =
|
||||||
'<div class="editor-bootstrap-card">' +
|
'<div class="ext-editor-bootstrap-card">' +
|
||||||
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="1.5" style="opacity:0.6;margin-bottom:12px;">' +
|
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="1.5" style="opacity:0.6;margin-bottom:12px;">' +
|
||||||
'<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>' +
|
'<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>' +
|
||||||
'</svg>' +
|
'</svg>' +
|
||||||
@@ -168,8 +168,8 @@
|
|||||||
'<span style="font-size:11px;color:var(--text-3);text-transform:uppercase;">or create new</span>' +
|
'<span style="font-size:11px;color:var(--text-3);text-transform:uppercase;">or create new</span>' +
|
||||||
'<div style="flex:1;height:1px;background:var(--border);"></div>' +
|
'<div style="flex:1;height:1px;background:var(--border);"></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<input type="text" id="editorBootstrapName" class="editor-bootstrap-input" placeholder="Workspace name" value="workspace">' +
|
'<input type="text" id="editorBootstrapName" class="ext-editor-bootstrap-input" placeholder="Workspace name" value="workspace">' +
|
||||||
'<button id="editorBootstrapBtn" class="editor-bootstrap-btn">Create Workspace</button>' +
|
'<button id="editorBootstrapBtn" class="ext-editor-bootstrap-btn">Create Workspace</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
}
|
}
|
||||||
workspaces.forEach(ws => {
|
workspaces.forEach(ws => {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.className = 'editor-ws-dropdown-item' + (ws.id === currentWsId ? ' active' : '');
|
item.className = 'ext-editor-ws-dropdown-item' + (ws.id === currentWsId ? ' active' : '');
|
||||||
item.textContent = ws.name || ws.id?.slice(0, 8);
|
item.textContent = ws.name || ws.id?.slice(0, 8);
|
||||||
item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
|
item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
|
||||||
listEl.appendChild(item);
|
listEl.appendChild(item);
|
||||||
@@ -243,10 +243,10 @@
|
|||||||
listEl.innerHTML = '';
|
listEl.innerHTML = '';
|
||||||
workspaces.forEach(ws => {
|
workspaces.forEach(ws => {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.className = 'editor-bootstrap-ws-item';
|
item.className = 'ext-editor-bootstrap-ws-item';
|
||||||
item.innerHTML =
|
item.innerHTML =
|
||||||
'<span class="editor-bootstrap-ws-name">' + esc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
|
'<span class="ext-editor-bootstrap-ws-name">' + esc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
|
||||||
'<span class="editor-bootstrap-ws-date">' + esc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
|
'<span class="ext-editor-bootstrap-ws-date">' + esc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
|
||||||
item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
|
item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
|
||||||
listEl.appendChild(item);
|
listEl.appendChild(item);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* Git Board — Surface Styles */
|
/* Git Board — Surface Styles */
|
||||||
|
|
||||||
.gb-shell {
|
.ext-git-board-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -9,80 +9,80 @@
|
|||||||
|
|
||||||
/* ── Header ──────────────────────────────── */
|
/* ── Header ──────────────────────────────── */
|
||||||
|
|
||||||
.gb-header {
|
.ext-git-board-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-header__left,
|
.ext-git-board-header__left,
|
||||||
.gb-header__right {
|
.ext-git-board-header__right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-title {
|
.ext-git-board-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-repo-picker {
|
.ext-git-board-repo-picker {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 5px 8px;
|
padding: 5px var(--sp-2);
|
||||||
max-width: 260px;
|
max-width: 260px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Connection Setup ─────────────────────── */
|
/* ── Connection Setup ─────────────────────── */
|
||||||
|
|
||||||
.gb-setup {
|
.ext-git-board-setup {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
margin: 60px auto;
|
margin: 60px auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0 16px;
|
padding: 0 var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-setup h2 {
|
.ext-git-board-setup h2 {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-setup p {
|
.ext-git-board-setup p {
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0 0 20px;
|
margin: 0 0 var(--sp-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.gb-setup__hint {
|
.ext-git-board-setup__hint {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Kanban Board ────────────────────────── */
|
/* ── Kanban Board ────────────────────────── */
|
||||||
|
|
||||||
.gb-board {
|
.ext-git-board-board {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
padding: 12px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-column {
|
.ext-git-board-column {
|
||||||
min-width: 260px;
|
min-width: 260px;
|
||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -94,15 +94,15 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-column__header {
|
.ext-git-board-column__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-column__title {
|
.ext-git-board-column__title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -110,67 +110,67 @@
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-column__count {
|
.ext-git-board-column__count {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-column__cards {
|
.ext-git-board-column__cards {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px;
|
padding: var(--sp-2);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Cards ───────────────────────────────── */
|
/* ── Cards ───────────────────────────────── */
|
||||||
|
|
||||||
.gb-card {
|
.ext-git-board-card {
|
||||||
display: block;
|
display: block;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 10px;
|
padding: var(--sp-3);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
transition: border-color var(--transition), background var(--transition);
|
transition: border-color var(--transition), background var(--transition);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card:hover {
|
.ext-git-board-card:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card--pr {
|
.ext-git-board-card--pr {
|
||||||
border-left: 3px solid var(--accent);
|
border-left: 3px solid var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__header {
|
.ext-git-board-card__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
margin-bottom: 4px;
|
margin-bottom: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__number {
|
.ext-git-board-card__number {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__assignee {
|
.ext-git-board-card__assignee {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__branch {
|
.ext-git-board-card__branch {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
@@ -181,54 +181,54 @@
|
|||||||
max-width: 150px;
|
max-width: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__title {
|
.ext-git-board-card__title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__labels {
|
.ext-git-board-card__labels {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
margin-top: 6px;
|
margin-top: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__labels .badge {
|
.ext-git-board-card__labels .ext-git-board-badge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 1px 6px;
|
padding: 1px var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-card__meta {
|
.ext-git-board-card__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-top: 6px;
|
margin-top: var(--sp-2);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── DnD States ─────────────────────────── */
|
/* ── DnD States ─────────────────────────── */
|
||||||
|
|
||||||
.gb-card[draggable="true"] {
|
.ext-git-board-card[draggable="true"] {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.gb-card[draggable="true"]:active {
|
.ext-git-board-card[draggable="true"]:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
.gb-column--dragover {
|
.ext-git-board-column--dragover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: color-mix(in srgb, var(--accent) 6%, var(--bg-surface));
|
background: color-mix(in srgb, var(--accent) 6%, var(--bg-surface));
|
||||||
}
|
}
|
||||||
.gb-column--dragover .gb-column__header {
|
.ext-git-board-column--dragover .ext-git-board-column__header {
|
||||||
border-bottom-color: var(--accent);
|
border-bottom-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Empty state ─────────────────────────── */
|
/* ── Empty state ─────────────────────────── */
|
||||||
|
|
||||||
.gb-empty {
|
.ext-git-board-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
|
|
||||||
/* ── Issue Detail Modal ─────────────────── */
|
/* ── Issue Detail Modal ─────────────────── */
|
||||||
|
|
||||||
.gb-modal-overlay {
|
.ext-git-board-modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0,0,0,0.5);
|
background: rgba(0,0,0,0.5);
|
||||||
@@ -247,40 +247,41 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px 16px;
|
padding: var(--sp-10) var(--sp-4);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal {
|
.ext-git-board-modal {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 680px;
|
max-width: 680px;
|
||||||
max-height: calc(100vh - 80px);
|
max-height: calc(100vh - 80px);
|
||||||
|
max-height: calc(100dvh - 80px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__header {
|
.ext-git-board-modal__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16px 20px;
|
padding: var(--sp-4) var(--sp-5);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__title-row {
|
.ext-git-board-modal__title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__title {
|
.ext-git-board-modal__title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -288,59 +289,59 @@
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__close {
|
.ext-git-board-modal__close {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 6px;
|
padding: 2px var(--sp-2);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.gb-modal__close:hover {
|
.ext-git-board-modal__close:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__body {
|
.ext-git-board-modal__body {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 16px 20px;
|
padding: var(--sp-4) var(--sp-5);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__meta {
|
.ext-git-board-modal__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--sp-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__date {
|
.ext-git-board-modal__date {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__extlink {
|
.ext-git-board-modal__extlink {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.gb-modal__extlink:hover {
|
.ext-git-board-modal__extlink:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__description {
|
.ext-git-board-modal__description {
|
||||||
margin-bottom: 20px;
|
margin-bottom: var(--sp-5);
|
||||||
padding-bottom: 16px;
|
padding-bottom: var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__body-text {
|
.ext-git-board-modal__body-text {
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -353,49 +354,49 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__empty {
|
.ext-git-board-modal__empty {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__section-title {
|
.ext-git-board-modal__section-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
margin: 0 0 12px;
|
margin: 0 0 var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Comments ────────────────────────────── */
|
/* ── Comments ────────────────────────────── */
|
||||||
|
|
||||||
.gb-comment {
|
.ext-git-board-comment {
|
||||||
padding: 10px 0;
|
padding: var(--sp-3) 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.gb-comment:last-child {
|
.ext-git-board-comment:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-comment__header {
|
.ext-git-board-comment__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
margin-bottom: 4px;
|
margin-bottom: var(--sp-1);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.gb-comment__header strong {
|
.ext-git-board-comment__header strong {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-comment__date {
|
.ext-git-board-comment__date {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-comment__body {
|
.ext-git-board-comment__body {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -405,13 +406,13 @@
|
|||||||
|
|
||||||
/* ── Add Comment ─────────────────────────── */
|
/* ── Add Comment ─────────────────────────── */
|
||||||
|
|
||||||
.gb-modal__add-comment {
|
.ext-git-board-modal__add-comment {
|
||||||
margin-top: 16px;
|
margin-top: var(--sp-4);
|
||||||
padding-top: 16px;
|
padding-top: var(--sp-4);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__textarea {
|
.ext-git-board-modal__textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -419,38 +420,31 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 8px 10px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
outline: none;
|
outline: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.gb-modal__textarea:focus {
|
.ext-git-board-modal__textarea:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gb-modal__actions {
|
.ext-git-board-modal__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
margin-top: 8px;
|
margin-top: var(--sp-2);
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Badge variants ──────────────────────── */
|
/* ── Badge variants ──────────────────────── */
|
||||||
|
|
||||||
.badge--green {
|
.ext-git-board-badge--green {
|
||||||
background: var(--green);
|
background: var(--green);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.badge--muted {
|
.ext-git-board-badge--muted {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
/* git-board danger button override — uses kernel .sw-btn--danger */
|
||||||
background: var(--danger, #e53e3e);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.btn-danger:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -160,21 +160,21 @@
|
|||||||
return html`
|
return html`
|
||||||
<div class="user-menu-container" ref=${menuRef}></div>
|
<div class="user-menu-container" ref=${menuRef}></div>
|
||||||
${needsConn ? html`<${ConnectionSetup} />` : html`
|
${needsConn ? html`<${ConnectionSetup} />` : html`
|
||||||
<div class="gb-shell">
|
<div class="ext-git-board-shell">
|
||||||
<header class="gb-header">
|
<header class="ext-git-board-header">
|
||||||
<div class="gb-header__left">
|
<div class="ext-git-board-header__left">
|
||||||
<h1 class="gb-title">Git Board</h1>
|
<h1 class="ext-git-board-title">Git Board</h1>
|
||||||
<${RepoPicker} repos=${repos} owner=${owner} repo=${repo}
|
<${RepoPicker} repos=${repos} owner=${owner} repo=${repo}
|
||||||
onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
|
onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
|
||||||
</div>
|
</div>
|
||||||
<div class="gb-header__right">
|
<div class="ext-git-board-header__right">
|
||||||
<button class="btn-small" onClick=${loadBoard} disabled=${loading}>
|
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${loadBoard} disabled=${loading}>
|
||||||
${loading ? '↻' : 'Refresh'}
|
${loading ? '↻' : 'Refresh'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
${board ? html`<${Board} data=${board} onDrop=${onDrop} onCardClick=${openModal} />` : html`
|
${board ? html`<${Board} data=${board} onDrop=${onDrop} onCardClick=${openModal} />` : html`
|
||||||
<div class="gb-empty">${loading ? 'Loading…' : 'Select a repository'}</div>
|
<div class="ext-git-board-empty">${loading ? 'Loading…' : 'Select a repository'}</div>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
@@ -186,21 +186,21 @@
|
|||||||
|
|
||||||
function ConnectionSetup() {
|
function ConnectionSetup() {
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-shell">
|
<div class="ext-git-board-shell">
|
||||||
<header class="gb-header">
|
<header class="ext-git-board-header">
|
||||||
<div class="gb-header__left"><h1 class="gb-title">Git Board</h1></div>
|
<div class="ext-git-board-header__left"><h1 class="ext-git-board-title">Git Board</h1></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="gb-setup">
|
<div class="ext-git-board-setup">
|
||||||
<h2>Connect to Gitea</h2>
|
<h2>Connect to Gitea</h2>
|
||||||
<p>Git Board requires a Gitea connection to fetch repositories, issues, and pull requests.</p>
|
<p>Git Board requires a Gitea connection to fetch repositories, issues, and pull requests.</p>
|
||||||
<p>Ask your admin to add a <strong>Gitea</strong> connection in
|
<p>Ask your admin to add a <strong>Gitea</strong> connection in
|
||||||
<strong>Admin → Connections</strong>, or add a personal one in
|
<strong>Admin → Connections</strong>, or add a personal one in
|
||||||
<strong>Settings → Connections</strong>.</p>
|
<strong>Settings → Connections</strong>.</p>
|
||||||
<button class="btn-small btn-primary"
|
<button class="sw-btn sw-btn--primary sw-btn--sm"
|
||||||
onClick=${function () { window.location.href = base + '/settings'; }}>
|
onClick=${function () { window.location.href = base + '/settings'; }}>
|
||||||
Open Settings
|
Open Settings
|
||||||
</button>
|
</button>
|
||||||
<p class="gb-setup__hint">Connections are managed centrally — no API tokens in extension settings.</p>
|
<p class="ext-git-board-setup__hint">Connections are managed centrally — no API tokens in extension settings.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -211,7 +211,7 @@
|
|||||||
function RepoPicker({ repos, owner, repo, onSelect }) {
|
function RepoPicker({ repos, owner, repo, onSelect }) {
|
||||||
var current = owner + '/' + repo;
|
var current = owner + '/' + repo;
|
||||||
return html`
|
return html`
|
||||||
<select class="gb-repo-picker" value=${current}
|
<select class="ext-git-board-repo-picker" value=${current}
|
||||||
onChange=${function (e) {
|
onChange=${function (e) {
|
||||||
var parts = e.target.value.split('/');
|
var parts = e.target.value.split('/');
|
||||||
onSelect(parts[0], parts.slice(1).join('/'));
|
onSelect(parts[0], parts.slice(1).join('/'));
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; });
|
var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; });
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-board">
|
<div class="ext-git-board-board">
|
||||||
<${Column} id="open" title="Open" count=${openUnassigned.length} onDrop=${onDrop}>
|
<${Column} id="open" title="Open" count=${openUnassigned.length} onDrop=${onDrop}>
|
||||||
${openUnassigned.map(function (i) {
|
${openUnassigned.map(function (i) {
|
||||||
return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
|
return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
|
||||||
@@ -268,19 +268,19 @@
|
|||||||
} : {};
|
} : {};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-column ${over ? 'gb-column--dragover' : ''}" ...${handlers}>
|
<div class="ext-git-board-column ${over ? 'ext-git-board-column--dragover' : ''}" ...${handlers}>
|
||||||
<div class="gb-column__header">
|
<div class="ext-git-board-column__header">
|
||||||
<span class="gb-column__title">${title}</span>
|
<span class="ext-git-board-column__title">${title}</span>
|
||||||
<span class="gb-column__count">${count || 0}</span>
|
<span class="ext-git-board-column__count">${count || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gb-column__cards">${children}</div>
|
<div class="ext-git-board-column__cards">${children}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function IssueCard({ issue, onClick }) {
|
function IssueCard({ issue, onClick }) {
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-card" draggable="true"
|
<div class="ext-git-board-card" draggable="true"
|
||||||
onDragStart=${function (e) {
|
onDragStart=${function (e) {
|
||||||
e.dataTransfer.setData('text/plain', String(issue.number));
|
e.dataTransfer.setData('text/plain', String(issue.number));
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
@@ -289,14 +289,14 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (onClick) onClick(issue.number);
|
if (onClick) onClick(issue.number);
|
||||||
}}>
|
}}>
|
||||||
<div class="gb-card__header">
|
<div class="ext-git-board-card__header">
|
||||||
<span class="gb-card__number">#${issue.number}</span>
|
<span class="ext-git-board-card__number">#${issue.number}</span>
|
||||||
${issue.assignee && html`<span class="gb-card__assignee">@${esc(issue.assignee)}</span>`}
|
${issue.assignee && html`<span class="ext-git-board-card__assignee">@${esc(issue.assignee)}</span>`}
|
||||||
</div>
|
</div>
|
||||||
<div class="gb-card__title">${esc(issue.title)}</div>
|
<div class="ext-git-board-card__title">${esc(issue.title)}</div>
|
||||||
<div class="gb-card__labels">
|
<div class="ext-git-board-card__labels">
|
||||||
${(issue.labels || []).map(function (l) {
|
${(issue.labels || []).map(function (l) {
|
||||||
return html`<span key=${l} class="badge">${esc(l)}</span>`;
|
return html`<span key=${l} class="ext-git-board-badge">${esc(l)}</span>`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,13 +305,13 @@
|
|||||||
|
|
||||||
function PRCard({ pr }) {
|
function PRCard({ pr }) {
|
||||||
return html`
|
return html`
|
||||||
<a class="gb-card gb-card--pr" href=${pr.html_url} target="_blank" rel="noopener">
|
<a class="ext-git-board-card ext-git-board-card--pr" href=${pr.html_url} target="_blank" rel="noopener">
|
||||||
<div class="gb-card__header">
|
<div class="ext-git-board-card__header">
|
||||||
<span class="gb-card__number">#${pr.number}</span>
|
<span class="ext-git-board-card__number">#${pr.number}</span>
|
||||||
<span class="gb-card__branch">${esc(pr.head)} → ${esc(pr.base)}</span>
|
<span class="ext-git-board-card__branch">${esc(pr.head)} → ${esc(pr.base)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gb-card__title">${esc(pr.title)}</div>
|
<div class="ext-git-board-card__title">${esc(pr.title)}</div>
|
||||||
<div class="gb-card__meta">
|
<div class="ext-git-board-card__meta">
|
||||||
<span>@${esc(pr.user)}</span>
|
<span>@${esc(pr.user)}</span>
|
||||||
<span>${timeAgo(pr.created_at)}</span>
|
<span>${timeAgo(pr.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -387,73 +387,73 @@
|
|||||||
}, [issue, owner, repo, number]);
|
}, [issue, owner, repo, number]);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-modal-overlay" onClick=${function (e) {
|
<div class="ext-git-board-modal-overlay" onClick=${function (e) {
|
||||||
if (e.target.classList.contains('gb-modal-overlay')) onClose(changed);
|
if (e.target.classList.contains('ext-git-board-modal-overlay')) onClose(changed);
|
||||||
}}>
|
}}>
|
||||||
<div class="gb-modal">
|
<div class="ext-git-board-modal">
|
||||||
<div class="gb-modal__header">
|
<div class="ext-git-board-modal__header">
|
||||||
<div class="gb-modal__title-row">
|
<div class="ext-git-board-modal__title-row">
|
||||||
${loading ? 'Loading…' : html`
|
${loading ? 'Loading…' : html`
|
||||||
<span class="gb-card__number">#${number}</span>
|
<span class="ext-git-board-card__number">#${number}</span>
|
||||||
<h2 class="gb-modal__title">${esc(issue && issue.title)}</h2>
|
<h2 class="ext-git-board-modal__title">${esc(issue && issue.title)}</h2>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
<button class="gb-modal__close" onClick=${function () { onClose(changed); }}>✕</button>
|
<button class="ext-git-board-modal__close" onClick=${function () { onClose(changed); }}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${!loading && issue && html`
|
${!loading && issue && html`
|
||||||
<div class="gb-modal__body" ref=${bodyRef}>
|
<div class="ext-git-board-modal__body" ref=${bodyRef}>
|
||||||
<div class="gb-modal__meta">
|
<div class="ext-git-board-modal__meta">
|
||||||
<span class="badge ${issue.state === 'open' ? 'badge--green' : 'badge--muted'}">${issue.state}</span>
|
<span class="ext-git-board-badge ${issue.state === 'open' ? 'ext-git-board-badge--green' : 'ext-git-board-badge--muted'}">${issue.state}</span>
|
||||||
${issue.assignee && html`<span class="gb-card__assignee">@${esc(issue.assignee)}</span>`}
|
${issue.assignee && html`<span class="ext-git-board-card__assignee">@${esc(issue.assignee)}</span>`}
|
||||||
${issue.created_at && html`<span class="gb-modal__date">${new Date(issue.created_at).toLocaleDateString()}</span>`}
|
${issue.created_at && html`<span class="ext-git-board-modal__date">${new Date(issue.created_at).toLocaleDateString()}</span>`}
|
||||||
<a href=${issue.html_url || '#'} target="_blank" rel="noopener"
|
<a href=${issue.html_url || '#'} target="_blank" rel="noopener"
|
||||||
class="gb-modal__extlink">Open in Gitea ↗</a>
|
class="ext-git-board-modal__extlink">Open in Gitea ↗</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${issue.labels && issue.labels.length > 0 && html`
|
${issue.labels && issue.labels.length > 0 && html`
|
||||||
<div class="gb-card__labels" style="margin-bottom:12px;">
|
<div class="ext-git-board-card__labels" style="margin-bottom:12px;">
|
||||||
${issue.labels.map(function (l) { return html`<span key=${l} class="badge">${esc(l)}</span>`; })}
|
${issue.labels.map(function (l) { return html`<span key=${l} class="ext-git-board-badge">${esc(l)}</span>`; })}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
|
|
||||||
<div class="gb-modal__description">
|
<div class="ext-git-board-modal__description">
|
||||||
${issue.body ? html`<pre class="gb-modal__body-text">${esc(issue.body)}</pre>`
|
${issue.body ? html`<pre class="ext-git-board-modal__body-text">${esc(issue.body)}</pre>`
|
||||||
: html`<p class="gb-modal__empty">No description.</p>`}
|
: html`<p class="ext-git-board-modal__empty">No description.</p>`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gb-modal__comments">
|
<div class="ext-git-board-modal__comments">
|
||||||
<h3 class="gb-modal__section-title">Comments (${(issue.comments || []).length})</h3>
|
<h3 class="ext-git-board-modal__section-title">Comments (${(issue.comments || []).length})</h3>
|
||||||
${(issue.comments || []).length === 0 && html`
|
${(issue.comments || []).length === 0 && html`
|
||||||
<p class="gb-modal__empty">No comments yet.</p>
|
<p class="ext-git-board-modal__empty">No comments yet.</p>
|
||||||
`}
|
`}
|
||||||
${(issue.comments || []).map(function (c, i) {
|
${(issue.comments || []).map(function (c, i) {
|
||||||
return html`
|
return html`
|
||||||
<div class="gb-comment" key=${i}>
|
<div class="ext-git-board-comment" key=${i}>
|
||||||
<div class="gb-comment__header">
|
<div class="ext-git-board-comment__header">
|
||||||
<strong>@${esc(c.user)}</strong>
|
<strong>@${esc(c.user)}</strong>
|
||||||
<span class="gb-comment__date">${timeAgo(c.created_at)}</span>
|
<span class="ext-git-board-comment__date">${timeAgo(c.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="gb-comment__body">${esc(c.body)}</div>
|
<div class="ext-git-board-comment__body">${esc(c.body)}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="gb-modal__add-comment">
|
<div class="ext-git-board-modal__add-comment">
|
||||||
<textarea class="gb-modal__textarea" rows="3"
|
<textarea class="ext-git-board-modal__textarea" rows="3"
|
||||||
placeholder="Add a comment…"
|
placeholder="Add a comment…"
|
||||||
value=${comment}
|
value=${comment}
|
||||||
onInput=${function (e) { setComment(e.target.value); }}
|
onInput=${function (e) { setComment(e.target.value); }}
|
||||||
onKeyDown=${function (e) {
|
onKeyDown=${function (e) {
|
||||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) postComment();
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) postComment();
|
||||||
}} />
|
}} />
|
||||||
<div class="gb-modal__actions">
|
<div class="ext-git-board-modal__actions">
|
||||||
<button class="btn-small btn-primary" disabled=${posting || !comment.trim()}
|
<button class="sw-btn sw-btn--primary sw-btn--sm" disabled=${posting || !comment.trim()}
|
||||||
onClick=${postComment}>
|
onClick=${postComment}>
|
||||||
${posting ? 'Posting…' : 'Comment'}
|
${posting ? 'Posting…' : 'Comment'}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-small ${issue.state === 'open' ? 'btn-danger' : 'btn-secondary'}"
|
<button class="sw-btn sw-btn--secondary sw-btn--sm ${issue.state === 'open' ? 'sw-btn sw-btn--danger sw-btn--md' : 'sw-btn sw-btn--secondary sw-btn--md'}"
|
||||||
onClick=${toggleState}>
|
onClick=${toggleState}>
|
||||||
${issue.state === 'open' ? 'Close Issue' : 'Reopen Issue'}
|
${issue.state === 'open' ? 'Close Issue' : 'Reopen Issue'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
Uses CSS custom properties from the platform theme system (variables.css).
|
Uses CSS custom properties from the platform theme system (variables.css).
|
||||||
See EXTENSION-SURFACES.md for the full property reference. */
|
See EXTENSION-SURFACES.md for the full property reference. */
|
||||||
|
|
||||||
.hello-dashboard { max-width: 720px; margin: 0 auto; padding: 40px 24px; }
|
.ext-hello-dashboard { max-width: 720px; margin: 0 auto; padding: var(--sp-10) var(--sp-6); }
|
||||||
.hello-header { margin-bottom: 32px; }
|
.ext-hello-dashboard-header { margin-bottom: var(--sp-8); }
|
||||||
.hello-header h1 { font-size: 28px; font-weight: 700; color: var(--text); margin: 0 0 8px 0; }
|
.ext-hello-dashboard-header h1 { font-size: 28px; font-weight: 700; color: var(--text); margin: 0 0 var(--sp-2) 0; }
|
||||||
.hello-subtitle { font-size: 14px; color: var(--text-2); margin: 0; }
|
.ext-hello-dashboard-subtitle { font-size: 14px; color: var(--text-2); margin: 0; }
|
||||||
.hello-subtitle code,
|
.ext-hello-dashboard-subtitle code,
|
||||||
.hello-card-value code { background: var(--bg-raised); padding: 2px 6px; border-radius: 4px; font-size: 13px; }
|
.ext-hello-dashboard-card-value code { background: var(--bg-raised); padding: 2px 6px; border-radius: var(--radius-sm); font-size: 13px; }
|
||||||
.hello-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
.ext-hello-dashboard-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--sp-4); margin-bottom: var(--sp-6); }
|
||||||
.hello-card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 16px; }
|
.ext-hello-dashboard-card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: var(--sp-4); }
|
||||||
.hello-card-title { font-size: 12px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
|
.ext-hello-dashboard-card-title { font-size: 12px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: var(--sp-2); }
|
||||||
.hello-card-value { font-size: 20px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
|
.ext-hello-dashboard-card-value { font-size: 20px; font-weight: 600; color: var(--text); margin-bottom: var(--sp-1); }
|
||||||
.hello-card-detail { font-size: 12px; color: var(--text-3); }
|
.ext-hello-dashboard-card-detail { font-size: 12px; color: var(--text-3); }
|
||||||
.hello-actions { display: flex; gap: 12px; margin-bottom: 24px; }
|
.ext-hello-dashboard-actions { display: flex; gap: var(--sp-3); margin-bottom: var(--sp-6); }
|
||||||
.hello-manifest { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; font-size: 12px; color: var(--text-2); overflow-x: auto; white-space: pre-wrap; font-family: var(--mono); line-height: 1.5; }
|
.ext-hello-dashboard-manifest { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--sp-4); font-size: 12px; color: var(--text-2); overflow-x: auto; white-space: pre-wrap; font-family: var(--mono); line-height: 1.5; }
|
||||||
|
|||||||
@@ -20,12 +20,12 @@
|
|||||||
var name = user.display_name || user.username || 'World';
|
var name = user.display_name || user.username || 'World';
|
||||||
|
|
||||||
mount.innerHTML =
|
mount.innerHTML =
|
||||||
'<div class="hello-dashboard">' +
|
'<div class="ext-hello-dashboard">' +
|
||||||
'<div class="hello-header">' +
|
'<div class="ext-hello-dashboard-header">' +
|
||||||
'<h1>Hello, ' + esc(name) + '!</h1>' +
|
'<h1>Hello, ' + esc(name) + '!</h1>' +
|
||||||
'<p class="hello-subtitle">Extension surface <code>' + esc(manifest.id || 'unknown') + '</code> loaded successfully.</p>' +
|
'<p class="ext-hello-dashboard-subtitle">Extension surface <code>' + esc(manifest.id || 'unknown') + '</code> loaded successfully.</p>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="hello-cards">' +
|
'<div class="ext-hello-dashboard-cards">' +
|
||||||
card('Platform Access', (typeof UI !== 'undefined' ? '\u2713 Connected' : '\u2717 Unavailable'),
|
card('Platform Access', (typeof UI !== 'undefined' ? '\u2713 Connected' : '\u2717 Unavailable'),
|
||||||
'API, Theme, UI primitives available', 'var(--accent)') +
|
'API, Theme, UI primitives available', 'var(--accent)') +
|
||||||
card('Theme', isDark ? '\uD83C\uDF19 Dark' : '\u2600\uFE0F Light',
|
card('Theme', isDark ? '\uD83C\uDF19 Dark' : '\u2600\uFE0F Light',
|
||||||
@@ -33,11 +33,11 @@
|
|||||||
card('Route', '<code>' + esc(manifest.route || '/s/hello-dashboard') + '</code>',
|
card('Route', '<code>' + esc(manifest.route || '/s/hello-dashboard') + '</code>',
|
||||||
'Registered via surface manifest', '') +
|
'Registered via surface manifest', '') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="hello-actions">' +
|
'<div class="ext-hello-dashboard-actions">' +
|
||||||
'<button class="btn-primary" id="helloToast">Show Toast</button>' +
|
'<button class="sw-btn sw-btn--primary sw-btn--md" id="helloToast">Show Toast</button>' +
|
||||||
'<button class="btn-secondary" id="helloApi">Test API</button>' +
|
'<button class="sw-btn sw-btn--secondary sw-btn--md" id="helloApi">Test API</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<pre class="hello-manifest">' + esc(JSON.stringify(manifest, null, 2)) + '</pre>' +
|
'<pre class="ext-hello-dashboard-manifest">' + esc(JSON.stringify(manifest, null, 2)) + '</pre>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
// Wire toast button
|
// Wire toast button
|
||||||
@@ -69,10 +69,10 @@
|
|||||||
|
|
||||||
function card(title, value, detail, color) {
|
function card(title, value, detail, color) {
|
||||||
var style = color ? ' style="color:' + color + ';"' : '';
|
var style = color ? ' style="color:' + color + ';"' : '';
|
||||||
return '<div class="hello-card">' +
|
return '<div class="ext-hello-dashboard-card">' +
|
||||||
'<div class="hello-card-title">' + esc(title) + '</div>' +
|
'<div class="ext-hello-dashboard-card-title">' + esc(title) + '</div>' +
|
||||||
'<div class="hello-card-value"' + style + '>' + value + '</div>' +
|
'<div class="ext-hello-dashboard-card-value"' + style + '>' + value + '</div>' +
|
||||||
'<div class="hello-card-detail">' + esc(detail) + '</div>' +
|
'<div class="ext-hello-dashboard-card-detail">' + esc(detail) + '</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +1,38 @@
|
|||||||
/* ICD Test Runner — Surface Styles */
|
/* ICD Test Runner — Surface Styles */
|
||||||
|
|
||||||
#extension-mount {
|
.ext-icd-test-runner-root {
|
||||||
font-family: var(--font, 'DM Sans', sans-serif);
|
font-family: var(--font, 'DM Sans', sans-serif);
|
||||||
}
|
}
|
||||||
|
|
||||||
#extension-mount h1 {
|
.ext-icd-test-runner-root h1 {
|
||||||
font-family: var(--font, 'DM Sans', sans-serif);
|
font-family: var(--font, 'DM Sans', sans-serif);
|
||||||
letter-spacing: -0.3px;
|
letter-spacing: -0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#extension-mount table {
|
.ext-icd-test-runner-root table {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
#extension-mount table td,
|
.ext-icd-test-runner-root table td,
|
||||||
#extension-mount table th {
|
.ext-icd-test-runner-root table th {
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
#extension-mount table tbody tr:last-child {
|
.ext-icd-test-runner-root table tbody tr:last-child {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#extension-mount table tbody tr:hover {
|
.ext-icd-test-runner-root table tbody tr:hover {
|
||||||
background: var(--bg-hover) !important;
|
background: var(--bg-hover) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Buttons — override small padding for our use */
|
/* Buttons — uses kernel sw-btn system, minor overrides for weight */
|
||||||
#extension-mount .btn-primary,
|
.ext-icd-test-runner-root .sw-btn {
|
||||||
#extension-mount .btn-secondary,
|
|
||||||
#extension-mount .btn-ghost {
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 7px 16px;
|
|
||||||
border-radius: var(--radius, 8px);
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: var(--font, 'DM Sans', sans-serif);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
transition: background var(--transition, 180ms ease), opacity var(--transition, 180ms ease);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fallback button styles if platform classes not loaded */
|
|
||||||
#extension-mount button.btn-primary {
|
|
||||||
background: var(--accent, #6c9fff);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
#extension-mount button.btn-primary:hover {
|
|
||||||
opacity: 0.88;
|
|
||||||
}
|
|
||||||
|
|
||||||
#extension-mount button.btn-secondary {
|
|
||||||
background: var(--accent-dim, rgba(108,159,255,0.12));
|
|
||||||
color: var(--accent, #6c9fff);
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
#extension-mount button.btn-secondary:hover {
|
|
||||||
background: var(--accent-dim, rgba(108,159,255,0.2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#extension-mount button.btn-ghost {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-2, #9898a8);
|
|
||||||
border: 1px solid var(--border, #2e2e35);
|
|
||||||
}
|
|
||||||
#extension-mount button.btn-ghost:hover {
|
|
||||||
background: var(--bg-hover, #2a2a30);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status dot animation for running state */
|
/* Status dot animation for running state */
|
||||||
@keyframes pulse-dot {
|
@keyframes ext-icd-test-runner-pulse-dot {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
50% { opacity: 0.4; }
|
50% { opacity: 0.4; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
|
|
||||||
var mount = document.getElementById('extension-mount');
|
var mount = document.getElementById('extension-mount');
|
||||||
if (!mount) return;
|
if (!mount) return;
|
||||||
|
mount.classList.add('ext-icd-test-runner-root');
|
||||||
|
|
||||||
// ─── Boot Preact SDK (v0.37.14) ───────────────────────────
|
// ─── Boot Preact SDK (v0.37.14) ───────────────────────────
|
||||||
// Extension surfaces don't load Preact vendors or the SDK.
|
// Extension surfaces don't load Preact vendors or the SDK.
|
||||||
|
|||||||
@@ -337,7 +337,7 @@
|
|||||||
await T.test('sdk', 'pipe-render', 'Render filter modifies DOM', async function () {
|
await T.test('sdk', 'pipe-render', 'Render filter modifies DOM', async function () {
|
||||||
sw.pipe.render(1, function (ctx) {
|
sw.pipe.render(1, function (ctx) {
|
||||||
if (ctx.element.dataset.sdkRenderTest) {
|
if (ctx.element.dataset.sdkRenderTest) {
|
||||||
ctx.element.classList.add('sdk-render-modified');
|
ctx.element.classList.add('ext-icd-test-runner-sdk-render-modified');
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}, { source: '_test-render-dom' });
|
}, { source: '_test-render-dom' });
|
||||||
@@ -348,7 +348,7 @@
|
|||||||
element: el, message: null,
|
element: el, message: null,
|
||||||
channel: { id: 'fake', type: 'direct' }
|
channel: { id: 'fake', type: 'direct' }
|
||||||
});
|
});
|
||||||
T.assert(el.classList.contains('sdk-render-modified'), 'element should have class');
|
T.assert(el.classList.contains('ext-icd-test-runner-sdk-render-modified'), 'element should have class');
|
||||||
});
|
});
|
||||||
|
|
||||||
await T.test('sdk', 'pipe-render', 'Priority ordering in render chain', async function () {
|
await T.test('sdk', 'pipe-render', 'Priority ordering in render chain', async function () {
|
||||||
|
|||||||
@@ -93,7 +93,7 @@
|
|||||||
|
|
||||||
// Configure button
|
// Configure button
|
||||||
var cfgBtn = $('button', {
|
var cfgBtn = $('button', {
|
||||||
className: T.providerSetup.configured ? 'btn-ghost' : 'btn-primary',
|
className: T.providerSetup.configured ? 'sw-btn sw-btn--ghost sw-btn--sm' : 'sw-btn sw-btn--primary sw-btn--md',
|
||||||
style: { alignSelf: 'flex-end', whiteSpace: 'nowrap' },
|
style: { alignSelf: 'flex-end', whiteSpace: 'nowrap' },
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
if (!T.providerSetup.apiKey) {
|
if (!T.providerSetup.apiKey) {
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
|
|
||||||
if (T.providerSetup.configured) {
|
if (T.providerSetup.configured) {
|
||||||
var clearBtn = $('button', {
|
var clearBtn = $('button', {
|
||||||
className: 'btn-ghost',
|
className: 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
style: { alignSelf: 'flex-end', color: 'var(--danger)', whiteSpace: 'nowrap' },
|
style: { alignSelf: 'flex-end', color: 'var(--danger)', whiteSpace: 'nowrap' },
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
T.providerSetup.apiKey = '';
|
T.providerSetup.apiKey = '';
|
||||||
@@ -189,19 +189,19 @@
|
|||||||
T.renderControls = function () {
|
T.renderControls = function () {
|
||||||
T.el.controls.innerHTML = '';
|
T.el.controls.innerHTML = '';
|
||||||
var btnSmoke = $('button', {
|
var btnSmoke = $('button', {
|
||||||
className: 'btn-primary',
|
className: 'sw-btn sw-btn--primary sw-btn--md',
|
||||||
onClick: function () { T.runSuite('smoke'); }
|
onClick: function () { T.runSuite('smoke'); }
|
||||||
}, 'Smoke');
|
}, 'Smoke');
|
||||||
var btnCrud = $('button', {
|
var btnCrud = $('button', {
|
||||||
className: 'btn-secondary',
|
className: 'sw-btn sw-btn--secondary sw-btn--md',
|
||||||
onClick: function () { T.runSuite('crud'); }
|
onClick: function () { T.runSuite('crud'); }
|
||||||
}, 'CRUD');
|
}, 'CRUD');
|
||||||
var btnAll = $('button', {
|
var btnAll = $('button', {
|
||||||
className: 'btn-primary',
|
className: 'sw-btn sw-btn--primary sw-btn--md',
|
||||||
onClick: function () { T.runSuite('all'); }
|
onClick: function () { T.runSuite('all'); }
|
||||||
}, 'Run All');
|
}, 'Run All');
|
||||||
var btnClear = $('button', {
|
var btnClear = $('button', {
|
||||||
className: 'btn-ghost',
|
className: 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
onClick: function () { T.results = []; T.renderProgress(); T.renderSummary(); T.renderDetail(); }
|
onClick: function () { T.results = []; T.renderProgress(); T.renderSummary(); T.renderDetail(); }
|
||||||
}, 'Clear');
|
}, 'Clear');
|
||||||
T.el.controls.appendChild(btnSmoke);
|
T.el.controls.appendChild(btnSmoke);
|
||||||
@@ -210,14 +210,14 @@
|
|||||||
// AuthZ button — only useful if admin (needs fixtures)
|
// AuthZ button — only useful if admin (needs fixtures)
|
||||||
if (T.user.role === 'admin') {
|
if (T.user.role === 'admin') {
|
||||||
var btnAuthz = $('button', {
|
var btnAuthz = $('button', {
|
||||||
className: T.fixtures.ready ? 'btn-secondary' : 'btn-ghost',
|
className: T.fixtures.ready ? 'sw-btn sw-btn--secondary sw-btn--md' : 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
style: { opacity: T.fixtures.ready ? '1' : '0.5' },
|
style: { opacity: T.fixtures.ready ? '1' : '0.5' },
|
||||||
onClick: function () { T.runSuite('authz'); }
|
onClick: function () { T.runSuite('authz'); }
|
||||||
}, 'AuthZ');
|
}, 'AuthZ');
|
||||||
T.el.controls.appendChild(btnAuthz);
|
T.el.controls.appendChild(btnAuthz);
|
||||||
|
|
||||||
var btnSecurity = $('button', {
|
var btnSecurity = $('button', {
|
||||||
className: T.fixtures.ready ? 'btn-secondary' : 'btn-ghost',
|
className: T.fixtures.ready ? 'sw-btn sw-btn--secondary sw-btn--md' : 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
style: {
|
style: {
|
||||||
opacity: T.fixtures.ready ? '1' : '0.5',
|
opacity: T.fixtures.ready ? '1' : '0.5',
|
||||||
borderColor: T.fixtures.ready ? 'var(--danger)' : undefined,
|
borderColor: T.fixtures.ready ? 'var(--danger)' : undefined,
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
|
|
||||||
// Provider tier button — needs provider setup
|
// Provider tier button — needs provider setup
|
||||||
var btnProv = $('button', {
|
var btnProv = $('button', {
|
||||||
className: T.providerSetup.configured ? 'btn-secondary' : 'btn-ghost',
|
className: T.providerSetup.configured ? 'sw-btn sw-btn--secondary sw-btn--md' : 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
style: { opacity: T.providerSetup.configured ? '1' : '0.5' },
|
style: { opacity: T.providerSetup.configured ? '1' : '0.5' },
|
||||||
onClick: function () { T.runSuite('provider'); }
|
onClick: function () { T.runSuite('provider'); }
|
||||||
}, 'Providers');
|
}, 'Providers');
|
||||||
@@ -238,7 +238,7 @@
|
|||||||
|
|
||||||
// SDK tier button — tests armature-sdk.js contract
|
// SDK tier button — tests armature-sdk.js contract
|
||||||
var btnSdk = $('button', {
|
var btnSdk = $('button', {
|
||||||
className: (typeof window.sw !== 'undefined') ? 'btn-secondary' : 'btn-ghost',
|
className: (typeof window.sw !== 'undefined') ? 'sw-btn sw-btn--secondary sw-btn--md' : 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
style: { opacity: (typeof window.sw !== 'undefined') ? '1' : '0.5' },
|
style: { opacity: (typeof window.sw !== 'undefined') ? '1' : '0.5' },
|
||||||
onClick: function () { T.runSuite('sdk'); }
|
onClick: function () { T.runSuite('sdk'); }
|
||||||
}, 'SDK');
|
}, 'SDK');
|
||||||
@@ -246,7 +246,7 @@
|
|||||||
|
|
||||||
// Packaging tier button — extension permission lifecycle (v0.29.0)
|
// Packaging tier button — extension permission lifecycle (v0.29.0)
|
||||||
var btnPkg = $('button', {
|
var btnPkg = $('button', {
|
||||||
className: 'btn-secondary',
|
className: 'sw-btn sw-btn--secondary sw-btn--md',
|
||||||
onClick: function () { T.runSuite('packaging'); }
|
onClick: function () { T.runSuite('packaging'); }
|
||||||
}, 'Packaging');
|
}, 'Packaging');
|
||||||
T.el.controls.appendChild(btnPkg);
|
T.el.controls.appendChild(btnPkg);
|
||||||
@@ -259,11 +259,11 @@
|
|||||||
|
|
||||||
// Export buttons
|
// Export buttons
|
||||||
var btnCopy = $('button', {
|
var btnCopy = $('button', {
|
||||||
className: 'btn-ghost',
|
className: 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
onClick: function () { T.exportReport('clipboard'); }
|
onClick: function () { T.exportReport('clipboard'); }
|
||||||
}, 'Copy Report');
|
}, 'Copy Report');
|
||||||
var btnDownload = $('button', {
|
var btnDownload = $('button', {
|
||||||
className: 'btn-ghost',
|
className: 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
onClick: function () { T.exportReport('download'); }
|
onClick: function () { T.exportReport('download'); }
|
||||||
}, 'Download');
|
}, 'Download');
|
||||||
T.el.controls.appendChild(btnCopy);
|
T.el.controls.appendChild(btnCopy);
|
||||||
@@ -274,12 +274,12 @@
|
|||||||
T.el.controls.appendChild($('div', { style: { width: '1px', height: '28px', background: 'var(--border)', flexShrink: '0' } }));
|
T.el.controls.appendChild($('div', { style: { width: '1px', height: '28px', background: 'var(--border)', flexShrink: '0' } }));
|
||||||
if (!T.fixtures.ready) {
|
if (!T.fixtures.ready) {
|
||||||
T.el.controls.appendChild($('button', {
|
T.el.controls.appendChild($('button', {
|
||||||
className: 'btn-secondary',
|
className: 'sw-btn sw-btn--secondary sw-btn--md',
|
||||||
onClick: async function () { await T.provisionFixtures(); T.renderControls(); }
|
onClick: async function () { await T.provisionFixtures(); T.renderControls(); }
|
||||||
}, 'Provision Test Users'));
|
}, 'Provision Test Users'));
|
||||||
} else {
|
} else {
|
||||||
T.el.controls.appendChild($('button', {
|
T.el.controls.appendChild($('button', {
|
||||||
className: 'btn-ghost',
|
className: 'sw-btn sw-btn--ghost sw-btn--sm',
|
||||||
style: { color: 'var(--danger)' },
|
style: { color: 'var(--danger)' },
|
||||||
onClick: async function () { await T.teardownFixtures(); T.renderControls(); }
|
onClick: async function () { await T.teardownFixtures(); T.renderControls(); }
|
||||||
}, 'Tear Down Fixtures'));
|
}, 'Tear Down Fixtures'));
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
/* ── Notes Surface Styles ─────────────────── */
|
/* ── Notes Surface Styles ─────────────────── */
|
||||||
|
|
||||||
.notes-app {
|
.ext-notes-app {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sidebar ─────────────────────────────── */
|
/* ── Sidebar ─────────────────────────────── */
|
||||||
.notes-sidebar {
|
.ext-notes-sidebar {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
@@ -16,106 +16,106 @@
|
|||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.notes-sidebar__header {
|
.ext-notes-sidebar__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
padding: 12px 14px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.notes-sidebar__title {
|
.ext-notes-sidebar__title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.notes-sidebar__count {
|
.ext-notes-sidebar__count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
.notes-sidebar__actions {
|
.ext-notes-sidebar__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Folder Tree ────────────────────────── */
|
/* ── Folder Tree ────────────────────────── */
|
||||||
.folder-tree {
|
.ext-notes-folder-tree {
|
||||||
padding: 4px 0;
|
padding: var(--sp-1) 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
max-height: 40%;
|
max-height: 40%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.folder-tree__item {
|
.ext-notes-folder-tree__item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
padding: 5px 12px;
|
padding: 5px var(--sp-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
margin: 1px 6px;
|
margin: 1px var(--sp-2);
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.folder-tree__item:hover {
|
.ext-notes-folder-tree__item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.folder-tree__item--active {
|
.ext-notes-folder-tree__item--active {
|
||||||
background: var(--bg-active);
|
background: var(--bg-active);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.folder-tree__toggle {
|
.ext-notes-folder-tree__toggle {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
.folder-tree__icon {
|
.ext-notes-folder-tree__icon {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.folder-tree__name {
|
.ext-notes-folder-tree__name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.folder-tree__edit {
|
.ext-notes-folder-tree__edit {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 1px 4px;
|
padding: 1px var(--sp-1);
|
||||||
border: 1px solid var(--accent);
|
border: 1px solid var(--accent);
|
||||||
border-radius: 3px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.folder-tree__add {
|
.ext-notes-folder-tree__add {
|
||||||
padding: 6px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.folder-tree__add:hover {
|
.ext-notes-folder-tree__add:hover {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Search ──────────────────────────────── */
|
/* ── Search ──────────────────────────────── */
|
||||||
.notes-search {
|
.ext-notes-search {
|
||||||
padding: 8px 14px;
|
padding: var(--sp-2) var(--sp-4);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.notes-search input {
|
.ext-notes-search input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 10px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -123,63 +123,63 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
}
|
}
|
||||||
.notes-search input:focus {
|
.ext-notes-search input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
.notes-search input::placeholder {
|
.ext-notes-search input::placeholder {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Note List ───────────────────────────── */
|
/* ── Note List ───────────────────────────── */
|
||||||
.notes-list {
|
.ext-notes-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
}
|
}
|
||||||
.notes-list__empty {
|
.ext-notes-list__empty {
|
||||||
padding: 40px 16px;
|
padding: var(--sp-10) var(--sp-4);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.notes-list__loading {
|
.ext-notes-list__loading {
|
||||||
padding: 40px 0;
|
padding: var(--sp-10) 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Note Card (sidebar item) ────────────── */
|
/* ── Note Card (sidebar item) ────────────── */
|
||||||
.note-card {
|
.ext-notes-note-card {
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
.note-card:hover {
|
.ext-notes-note-card:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
.note-card--active {
|
.ext-notes-note-card--active {
|
||||||
background: var(--bg-active);
|
background: var(--bg-active);
|
||||||
}
|
}
|
||||||
.note-card__title {
|
.ext-notes-note-card__title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.note-card__pin {
|
.ext-notes-note-card__pin {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.note-card__snippet {
|
.ext-notes-note-card__snippet {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
@@ -188,21 +188,21 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
.note-card__date {
|
.ext-notes-note-card__date {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Editor Pane ─────────────────────────── */
|
/* ── Editor Pane ─────────────────────────── */
|
||||||
.notes-editor {
|
.ext-notes-editor {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.notes-editor__empty {
|
.ext-notes-editor__empty {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -212,15 +212,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Editor Header ───────────────────────── */
|
/* ── Editor Header ───────────────────────── */
|
||||||
.notes-editor__header {
|
.ext-notes-editor__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
padding: 10px 20px;
|
padding: var(--sp-3) var(--sp-5);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.notes-editor__title-input {
|
.ext-notes-editor__title-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -228,22 +228,22 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 4px 0;
|
padding: var(--sp-1) 0;
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
}
|
}
|
||||||
.notes-editor__title-input::placeholder {
|
.ext-notes-editor__title-input::placeholder {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
.notes-editor__actions {
|
.ext-notes-editor__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Folder select in editor ────────────── */
|
/* ── Folder select in editor ────────────── */
|
||||||
.notes-editor__folder-select {
|
.ext-notes-editor__folder-select {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 3px 6px;
|
padding: 3px var(--sp-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
@@ -252,13 +252,13 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
max-width: 140px;
|
max-width: 140px;
|
||||||
}
|
}
|
||||||
.notes-editor__folder-select:focus {
|
.ext-notes-editor__folder-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Editor Content (body + outline wrapper) */
|
/* ── Editor Content (body + outline wrapper) */
|
||||||
.notes-editor__content {
|
.ext-notes-editor__content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -266,23 +266,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Editor Body ─────────────────────────── */
|
/* ── Editor Body ─────────────────────────── */
|
||||||
.notes-editor__body {
|
.ext-notes-editor__body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.notes-editor__body--split {
|
.ext-notes-editor__body--split {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
.notes-editor__body--split .notes-preview {
|
.ext-notes-editor__body--split .ext-notes-preview {
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.notes-editor__textarea {
|
.ext-notes-editor__textarea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
resize: none;
|
resize: none;
|
||||||
padding: 20px;
|
padding: var(--sp-5);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -292,79 +292,79 @@
|
|||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
tab-size: 2;
|
tab-size: 2;
|
||||||
}
|
}
|
||||||
.notes-editor__textarea::placeholder {
|
.ext-notes-editor__textarea::placeholder {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── CodeMirror 6 container ─────────────── */
|
/* ── CodeMirror 6 container ─────────────── */
|
||||||
.notes-editor__cm {
|
.ext-notes-editor__cm {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.notes-editor__cm .cm-editor {
|
.ext-notes-editor__cm .cm-editor {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
}
|
}
|
||||||
.notes-editor__cm .cm-scroller {
|
.ext-notes-editor__cm .cm-scroller {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 12px 20px;
|
padding: var(--sp-3) var(--sp-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Preview ─────────────────────────────── */
|
/* ── Preview ─────────────────────────────── */
|
||||||
.notes-preview {
|
.ext-notes-preview {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 20px;
|
padding: var(--sp-5);
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
}
|
}
|
||||||
.notes-preview h1 { font-size: 24px; font-weight: 700; color: var(--text); margin: 0 0 12px; }
|
.ext-notes-preview h1 { font-size: 24px; font-weight: 700; color: var(--text); margin: 0 0 var(--sp-3); }
|
||||||
.notes-preview h2 { font-size: 20px; font-weight: 600; color: var(--text); margin: 20px 0 8px; }
|
.ext-notes-preview h2 { font-size: 20px; font-weight: 600; color: var(--text); margin: var(--sp-5) 0 var(--sp-2); }
|
||||||
.notes-preview h3 { font-size: 16px; font-weight: 600; color: var(--text); margin: 16px 0 6px; }
|
.ext-notes-preview h3 { font-size: 16px; font-weight: 600; color: var(--text); margin: var(--sp-4) 0 var(--sp-2); }
|
||||||
.notes-preview p { font-size: 14px; color: var(--text); line-height: 1.7; margin: 0 0 10px; }
|
.ext-notes-preview p { font-size: 14px; color: var(--text); line-height: 1.7; margin: 0 0 var(--sp-3); }
|
||||||
.notes-preview ul, .notes-preview ol { padding-left: 24px; margin: 0 0 10px; color: var(--text); font-size: 14px; line-height: 1.7; }
|
.ext-notes-preview ul, .ext-notes-preview ol { padding-left: var(--sp-6); margin: 0 0 var(--sp-3); color: var(--text); font-size: 14px; line-height: 1.7; }
|
||||||
.notes-preview li { margin-bottom: 2px; }
|
.ext-notes-preview li { margin-bottom: 2px; }
|
||||||
.notes-preview code {
|
.ext-notes-preview code {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
padding: 2px 6px;
|
padding: 2px var(--sp-2);
|
||||||
border-radius: 3px;
|
border-radius: var(--radius-sm);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
.notes-preview pre {
|
.ext-notes-preview pre {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 14px;
|
padding: var(--sp-4);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 0 0 12px;
|
margin: 0 0 var(--sp-3);
|
||||||
}
|
}
|
||||||
.notes-preview pre code {
|
.ext-notes-preview pre code {
|
||||||
background: none;
|
background: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.notes-preview blockquote {
|
.ext-notes-preview blockquote {
|
||||||
border-left: 3px solid var(--accent);
|
border-left: 3px solid var(--accent);
|
||||||
margin: 0 0 12px;
|
margin: 0 0 var(--sp-3);
|
||||||
padding: 4px 16px;
|
padding: var(--sp-1) var(--sp-4);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
.notes-preview a { color: var(--accent); text-decoration: none; }
|
.ext-notes-preview a { color: var(--accent); text-decoration: none; }
|
||||||
.notes-preview a:hover { text-decoration: underline; }
|
.ext-notes-preview a:hover { text-decoration: underline; }
|
||||||
.notes-preview hr {
|
.ext-notes-preview hr {
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
margin: 16px 0;
|
margin: var(--sp-4) 0;
|
||||||
}
|
}
|
||||||
.notes-preview strong { font-weight: 600; }
|
.ext-notes-preview strong { font-weight: 600; }
|
||||||
.notes-preview em { font-style: italic; }
|
.ext-notes-preview em { font-style: italic; }
|
||||||
|
|
||||||
/* ── Toggle button ───────────────────────── */
|
/* ── Toggle button ───────────────────────── */
|
||||||
.notes-toggle {
|
.ext-notes-toggle {
|
||||||
padding: 4px 10px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
@@ -373,43 +373,43 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.notes-toggle:hover { background: var(--bg-hover); color: var(--text); }
|
.ext-notes-toggle:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
.notes-toggle--active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
.ext-notes-toggle--active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
|
||||||
/* ── Inline buttons ──────────────────────── */
|
/* ── Inline buttons ──────────────────────── */
|
||||||
.notes-btn {
|
.ext-notes-btn {
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
padding: 4px 10px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.notes-btn:hover { background: var(--bg-hover); color: var(--text); }
|
.ext-notes-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
.notes-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
|
.ext-notes-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
|
||||||
.notes-btn--accent { background: var(--accent); color: #fff; }
|
.ext-notes-btn--accent { background: var(--accent); color: #fff; }
|
||||||
.notes-btn--accent:hover { opacity: 0.9; color: #fff; }
|
.ext-notes-btn--accent:hover { opacity: 0.9; color: #fff; }
|
||||||
|
|
||||||
/* ── Saved indicator ─────────────────────── */
|
/* ── Saved indicator ─────────────────────── */
|
||||||
.notes-saved {
|
.ext-notes-saved {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
padding: 2px 8px;
|
padding: 2px var(--sp-2);
|
||||||
}
|
}
|
||||||
.notes-saved--dirty {
|
.ext-notes-saved--dirty {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tag Pills (shared) ─────────────────── */
|
/* ── Tag Pills (shared) ─────────────────── */
|
||||||
.tag-pill {
|
.ext-notes-tag-pill {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 1px 8px;
|
padding: 1px var(--sp-2);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
margin: 2px 2px;
|
margin: 2px 2px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -417,23 +417,23 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.tag-pill:hover { background: var(--bg-hover); color: var(--text); }
|
.ext-notes-tag-pill:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
.tag-pill--active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
.ext-notes-tag-pill--active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
.tag-pill__remove {
|
.ext-notes-tag-pill__remove {
|
||||||
margin-left: 4px;
|
margin-left: var(--sp-1);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.tag-pill__remove:hover { opacity: 1; }
|
.ext-notes-tag-pill__remove:hover { opacity: 1; }
|
||||||
|
|
||||||
/* ── Tag Filter (sidebar) ──────────────── */
|
/* ── Tag Filter (sidebar) ──────────────── */
|
||||||
.tag-filter {
|
.ext-notes-tag-filter {
|
||||||
padding: 6px 14px 4px;
|
padding: var(--sp-2) var(--sp-4) var(--sp-1);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.tag-filter__label {
|
.ext-notes-tag-filter__label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
@@ -441,7 +441,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.tag-filter__clear {
|
.ext-notes-tag-filter__clear {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -450,27 +450,27 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
}
|
}
|
||||||
.tag-filter__clear:hover { text-decoration: underline; }
|
.ext-notes-tag-filter__clear:hover { text-decoration: underline; }
|
||||||
.tag-filter__pills {
|
.ext-notes-tag-filter__pills {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
max-height: 60px;
|
max-height: 60px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.tag-filter__pills .tag-pill { font-size: 10px; padding: 0 6px; }
|
.ext-notes-tag-filter__pills .ext-notes-tag-pill { font-size: 10px; padding: 0 var(--sp-2); }
|
||||||
|
|
||||||
/* ── Tag Input (editor) ────────────────── */
|
/* ── Tag Input (editor) ────────────────── */
|
||||||
.tag-input {
|
.ext-notes-tag-input {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 4px 20px 6px;
|
padding: var(--sp-1) var(--sp-5) var(--sp-2);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.tag-input__field {
|
.ext-notes-tag-input__field {
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -481,14 +481,14 @@
|
|||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
}
|
}
|
||||||
.tag-input__field::placeholder { color: var(--text-3); }
|
.ext-notes-tag-input__field::placeholder { color: var(--text-3); }
|
||||||
.tag-input .tag-pill { font-size: 11px; }
|
.ext-notes-tag-input .ext-notes-tag-pill { font-size: 11px; }
|
||||||
|
|
||||||
/* ── Tag Autocomplete ──────────────────── */
|
/* ── Tag Autocomplete ──────────────────── */
|
||||||
.tag-autocomplete {
|
.ext-notes-tag-autocomplete {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%;
|
||||||
left: 20px;
|
left: var(--sp-5);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -498,23 +498,23 @@
|
|||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
.tag-autocomplete__item {
|
.ext-notes-tag-autocomplete__item {
|
||||||
padding: 4px 10px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.tag-autocomplete__item:hover { background: var(--bg-hover); }
|
.ext-notes-tag-autocomplete__item:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
/* ── Note Card Tags ────────────────────── */
|
/* ── Note Card Tags ────────────────────── */
|
||||||
.note-card__tags {
|
.ext-notes-note-card__tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.note-card__tags .tag-pill { font-size: 10px; padding: 0 6px; cursor: default; }
|
.ext-notes-note-card__tags .ext-notes-tag-pill { font-size: 10px; padding: 0 var(--sp-2); cursor: default; }
|
||||||
.note-card__tags .tag-pill--overflow {
|
.ext-notes-note-card__tags .ext-notes-tag-pill--overflow {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
@@ -523,7 +523,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Folder Context Menu ───────────────── */
|
/* ── Folder Context Menu ───────────────── */
|
||||||
.folder-context-menu {
|
.ext-notes-folder-context-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
@@ -531,116 +531,116 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
|
||||||
min-width: 150px;
|
min-width: 150px;
|
||||||
padding: 4px 0;
|
padding: var(--sp-1) 0;
|
||||||
}
|
}
|
||||||
.folder-context-menu__item {
|
.ext-notes-folder-context-menu__item {
|
||||||
padding: 6px 14px;
|
padding: var(--sp-2) var(--sp-4);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.folder-context-menu__item:hover {
|
.ext-notes-folder-context-menu__item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
.folder-context-menu__item--danger {
|
.ext-notes-folder-context-menu__item--danger {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
.folder-context-menu__item--danger:hover {
|
.ext-notes-folder-context-menu__item--danger:hover {
|
||||||
background: var(--danger-dim);
|
background: var(--danger-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Drag and Drop ─────────────────────── */
|
/* ── Drag and Drop ─────────────────────── */
|
||||||
.note-card--dragging { opacity: 0.4; }
|
.ext-notes-note-card--dragging { opacity: 0.4; }
|
||||||
.folder-tree__item--drop-target {
|
.ext-notes-folder-tree__item--drop-target {
|
||||||
background: color-mix(in srgb, var(--accent) 10%, transparent) !important;
|
background: color-mix(in srgb, var(--accent) 10%, transparent) !important;
|
||||||
outline: 2px dashed var(--accent);
|
outline: 2px dashed var(--accent);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Wikilinks ──────────────────────────── */
|
/* ── Wikilinks ──────────────────────────── */
|
||||||
.wikilink {
|
.ext-notes-wikilink {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-bottom: 1px dashed var(--accent);
|
border-bottom: 1px dashed var(--accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.wikilink:hover {
|
.ext-notes-wikilink:hover {
|
||||||
border-bottom-style: solid;
|
border-bottom-style: solid;
|
||||||
}
|
}
|
||||||
.wikilink.wikilink--unresolved {
|
.ext-notes-wikilink.ext-notes-wikilink--unresolved {
|
||||||
color: var(--danger, #e53e3e);
|
color: var(--danger);
|
||||||
border-bottom-color: var(--danger, #e53e3e);
|
border-bottom-color: var(--danger);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
.wikilink.wikilink--unresolved:hover {
|
.ext-notes-wikilink.ext-notes-wikilink--unresolved:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Backlinks Panel ────────────────────── */
|
/* ── Backlinks Panel ────────────────────── */
|
||||||
.backlinks-panel {
|
.ext-notes-backlinks-panel {
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
padding: 10px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.backlinks-panel__header {
|
.ext-notes-backlinks-panel__header {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: var(--sp-2);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.backlinks-panel__toggle {
|
.ext-notes-backlinks-panel__toggle {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
.backlinks-panel__count {
|
.ext-notes-backlinks-panel__count {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 0 6px;
|
padding: 0 var(--sp-2);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
.backlinks-panel__item {
|
.ext-notes-backlinks-panel__item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.backlinks-panel__item:hover {
|
.ext-notes-backlinks-panel__item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
.backlinks-panel__title {
|
.ext-notes-backlinks-panel__title {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.backlinks-panel__context {
|
.ext-notes-backlinks-panel__context {
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sidebar Tabs ────────────────────────── */
|
/* ── Sidebar Tabs ────────────────────────── */
|
||||||
.sidebar-tabs {
|
.ext-notes-sidebar-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.sidebar-tabs__tab {
|
.ext-notes-sidebar-tabs__tab {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 10px 14px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
@@ -652,38 +652,38 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
}
|
}
|
||||||
.sidebar-tabs__tab:hover {
|
.ext-notes-sidebar-tabs__tab:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
.sidebar-tabs__tab--active {
|
.ext-notes-sidebar-tabs__tab--active {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border-bottom-color: var(--accent);
|
border-bottom-color: var(--accent);
|
||||||
}
|
}
|
||||||
.sidebar-tabs__tab--disabled {
|
.ext-notes-sidebar-tabs__tab--disabled {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
.sidebar-tabs__tab--disabled:hover {
|
.ext-notes-sidebar-tabs__tab--disabled:hover {
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Sidebar Outline ─────────────────────── */
|
/* ── Sidebar Outline ─────────────────────── */
|
||||||
.sidebar-outline {
|
.ext-notes-sidebar-outline {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px 0;
|
padding: var(--sp-2) 0;
|
||||||
}
|
}
|
||||||
.sidebar-outline__empty {
|
.ext-notes-sidebar-outline__empty {
|
||||||
padding: 40px 16px;
|
padding: var(--sp-10) var(--sp-4);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.sidebar-outline__item {
|
.ext-notes-sidebar-outline__item {
|
||||||
padding: 5px 12px;
|
padding: 5px var(--sp-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -692,104 +692,104 @@
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
margin: 1px 6px;
|
margin: 1px var(--sp-2);
|
||||||
}
|
}
|
||||||
.sidebar-outline__item:hover {
|
.ext-notes-sidebar-outline__item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.sidebar-outline__item--active {
|
.ext-notes-sidebar-outline__item--active {
|
||||||
background: var(--bg-active);
|
background: var(--bg-active);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.sidebar-outline__item--h1 { font-weight: 600; color: var(--text); }
|
.ext-notes-sidebar-outline__item--h1 { font-weight: 600; color: var(--text); }
|
||||||
.sidebar-outline__item--h2 { font-weight: 500; }
|
.ext-notes-sidebar-outline__item--h2 { font-weight: 500; }
|
||||||
|
|
||||||
/* ── Graph Pane ─────────────────────────── */
|
/* ── Graph Pane ─────────────────────────── */
|
||||||
.graph-pane {
|
.ext-notes-graph-pane {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-surface, #fff);
|
background: var(--bg-surface);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.graph-pane canvas {
|
.ext-notes-graph-pane canvas {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
}
|
}
|
||||||
.graph-pane canvas:active { cursor: grabbing; }
|
.ext-notes-graph-pane canvas:active { cursor: grabbing; }
|
||||||
.graph-toolbar {
|
.ext-notes-graph-toolbar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: var(--sp-3);
|
||||||
right: 12px;
|
right: var(--sp-3);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-2, #666);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
.graph-toolbar label {
|
.ext-notes-graph-toolbar label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.graph-toolbar input[type="checkbox"] { margin: 0; }
|
.ext-notes-graph-toolbar input[type="checkbox"] { margin: 0; }
|
||||||
.graph-tooltip {
|
.ext-notes-graph-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background: var(--bg-raised, #fff);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border, #ddd);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius, 6px);
|
border-radius: var(--radius, 6px);
|
||||||
padding: 8px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.graph-tooltip__title {
|
.ext-notes-graph-tooltip__title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
.graph-tooltip__tags {
|
.ext-notes-graph-tooltip__tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 4px;
|
margin-top: var(--sp-1);
|
||||||
}
|
}
|
||||||
.graph-tooltip__tags .tag-pill {
|
.ext-notes-graph-tooltip__tags .ext-notes-tag-pill {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 1px 6px;
|
padding: 1px var(--sp-2);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
background: var(--bg-hover, #eee);
|
background: var(--bg-hover);
|
||||||
color: var(--text-2, #666);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
.graph-tooltip__edges {
|
.ext-notes-graph-tooltip__edges {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3, #999);
|
color: var(--text-3);
|
||||||
margin-top: 4px;
|
margin-top: var(--sp-1);
|
||||||
}
|
}
|
||||||
.graph-empty {
|
.ext-notes-graph-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
color: var(--text-3, #999);
|
color: var(--text-3);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Responsive ──────────────────────────── */
|
/* ── Responsive ──────────────────────────── */
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 768px) {
|
||||||
.notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
|
.ext-notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
|
||||||
.notes-app { flex-direction: column; }
|
.ext-notes-app { flex-direction: column; }
|
||||||
.folder-tree { max-height: 30vh; }
|
.ext-notes-folder-tree { max-height: 30vh; }
|
||||||
.notes-editor__body--split { grid-template-columns: 1fr; }
|
.ext-notes-editor__body--split { grid-template-columns: 1fr; }
|
||||||
.notes-editor__body--split .notes-preview { display: none; }
|
.ext-notes-editor__body--split .ext-notes-preview { display: none; }
|
||||||
.graph-pane { min-height: 50vh; }
|
.ext-notes-graph-pane { min-height: 50vh; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,16 +132,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="tag-input">
|
<div class="ext-notes-tag-input">
|
||||||
${(tags || []).map(function(tag) {
|
${(tags || []).map(function(tag) {
|
||||||
return html`
|
return html`
|
||||||
<span key=${tag} class="tag-pill">
|
<span key=${tag} class="ext-notes-tag-pill">
|
||||||
${tag}
|
${tag}
|
||||||
<span class="tag-pill__remove" onClick=${function() { removeTag(tag); }}>×</span>
|
<span class="ext-notes-tag-pill__remove" onClick=${function() { removeTag(tag); }}>×</span>
|
||||||
</span>
|
</span>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
<input ref=${inputRef} class="tag-input__field" type="text"
|
<input ref=${inputRef} class="ext-notes-tag-input__field" type="text"
|
||||||
placeholder=${(tags && tags.length) ? 'Add tag…' : 'Add tags (comma to separate)…'}
|
placeholder=${(tags && tags.length) ? 'Add tag…' : 'Add tags (comma to separate)…'}
|
||||||
value=${input}
|
value=${input}
|
||||||
onInput=${handleInput}
|
onInput=${handleInput}
|
||||||
@@ -149,9 +149,9 @@
|
|||||||
onFocus=${function() { if (input.trim()) setShowAC(true); }}
|
onFocus=${function() { if (input.trim()) setShowAC(true); }}
|
||||||
onBlur=${function() { setTimeout(function() { setShowAC(false); }, 150); }} />
|
onBlur=${function() { setTimeout(function() { setShowAC(false); }, 150); }} />
|
||||||
${showAC && suggestions.length > 0 && html`
|
${showAC && suggestions.length > 0 && html`
|
||||||
<div class="tag-autocomplete">
|
<div class="ext-notes-tag-autocomplete">
|
||||||
${suggestions.map(function(s) {
|
${suggestions.map(function(s) {
|
||||||
return html`<div key=${s} class="tag-autocomplete__item"
|
return html`<div key=${s} class="ext-notes-tag-autocomplete__item"
|
||||||
onClick=${function() { addTag(s); }}>${s}</div>`;
|
onClick=${function() { addTag(s); }}>${s}</div>`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -169,17 +169,17 @@
|
|||||||
if (!tags || tags.length === 0) return null;
|
if (!tags || tags.length === 0) return null;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="tag-filter">
|
<div class="ext-notes-tag-filter">
|
||||||
<div class="tag-filter__label">
|
<div class="ext-notes-tag-filter__label">
|
||||||
<span>Tags</span>
|
<span>Tags</span>
|
||||||
${activeTag && html`
|
${activeTag && html`
|
||||||
<button class="tag-filter__clear" onClick=${function() { onSelect(null); }}>Clear</button>
|
<button class="ext-notes-tag-filter__clear" onClick=${function() { onSelect(null); }}>Clear</button>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
<div class="tag-filter__pills">
|
<div class="ext-notes-tag-filter__pills">
|
||||||
${tags.map(function(tag) {
|
${tags.map(function(tag) {
|
||||||
return html`
|
return html`
|
||||||
<span key=${tag} class="tag-pill ${activeTag === tag ? 'tag-pill--active' : ''}"
|
<span key=${tag} class="ext-notes-tag-pill ${activeTag === tag ? 'ext-notes-tag-pill--active' : ''}"
|
||||||
onClick=${function() { onSelect(activeTag === tag ? null : tag); }}>
|
onClick=${function() { onSelect(activeTag === tag ? null : tag); }}>
|
||||||
${tag}
|
${tag}
|
||||||
</span>
|
</span>
|
||||||
@@ -216,23 +216,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="note-card ${active ? 'note-card--active' : ''} ${dragging ? 'note-card--dragging' : ''}"
|
<div class="ext-notes-note-card ${active ? 'ext-notes-note-card--active' : ''} ${dragging ? 'ext-notes-note-card--dragging' : ''}"
|
||||||
onClick=${onClick}
|
onClick=${onClick}
|
||||||
draggable="true"
|
draggable="true"
|
||||||
onDragStart=${handleDragStart}
|
onDragStart=${handleDragStart}
|
||||||
onDragEnd=${handleDragEnd}>
|
onDragEnd=${handleDragEnd}>
|
||||||
<div class="note-card__title">
|
<div class="ext-notes-note-card__title">
|
||||||
${note.pinned ? html`<span class="note-card__pin">📌</span>` : null}
|
${note.pinned ? html`<span class="ext-notes-note-card__pin">📌</span>` : null}
|
||||||
${note.title || 'Untitled'}
|
${note.title || 'Untitled'}
|
||||||
</div>
|
</div>
|
||||||
${note.snippet && html`<div class="note-card__snippet">${note.snippet}</div>`}
|
${note.snippet && html`<div class="ext-notes-note-card__snippet">${note.snippet}</div>`}
|
||||||
${showTags.length > 0 && html`
|
${showTags.length > 0 && html`
|
||||||
<div class="note-card__tags">
|
<div class="ext-notes-note-card__tags">
|
||||||
${showTags.map(function(t) { return html`<span key=${t} class="tag-pill">${t}</span>`; })}
|
${showTags.map(function(t) { return html`<span key=${t} class="ext-notes-tag-pill">${t}</span>`; })}
|
||||||
${overflow > 0 && html`<span class="tag-pill tag-pill--overflow">+${overflow}</span>`}
|
${overflow > 0 && html`<span class="ext-notes-tag-pill ext-notes-tag-pill--overflow">+${overflow}</span>`}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
${dateStr && html`<div class="note-card__date">${dateStr}</div>`}
|
${dateStr && html`<div class="ext-notes-note-card__date">${dateStr}</div>`}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -254,10 +254,10 @@
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div ref=${menuRef} class="folder-context-menu" style="top: ${y}px; left: ${x}px">
|
<div ref=${menuRef} class="ext-notes-folder-context-menu" style="top: ${y}px; left: ${x}px">
|
||||||
<div class="folder-context-menu__item" onClick=${onAddSub}>Add subfolder</div>
|
<div class="ext-notes-folder-context-menu__item" onClick=${onAddSub}>Add subfolder</div>
|
||||||
<div class="folder-context-menu__item" onClick=${onRename}>Rename</div>
|
<div class="ext-notes-folder-context-menu__item" onClick=${onRename}>Rename</div>
|
||||||
<div class="folder-context-menu__item folder-context-menu__item--danger" onClick=${onDelete}>Delete</div>
|
<div class="ext-notes-folder-context-menu__item ext-notes-folder-context-menu__item--danger" onClick=${onDelete}>Delete</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -310,25 +310,25 @@
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div>
|
<div>
|
||||||
<div class="folder-tree__item ${isActive ? 'folder-tree__item--active' : ''} ${dropOver ? 'folder-tree__item--drop-target' : ''}"
|
<div class="ext-notes-folder-tree__item ${isActive ? 'ext-notes-folder-tree__item--active' : ''} ${dropOver ? 'ext-notes-folder-tree__item--drop-target' : ''}"
|
||||||
style="padding-left: ${indent}px"
|
style="padding-left: ${indent}px"
|
||||||
onClick=${function() { onSelect(node.folder.id); }}
|
onClick=${function() { onSelect(node.folder.id); }}
|
||||||
onContextMenu=${handleContext}
|
onContextMenu=${handleContext}
|
||||||
onDragOver=${handleDragOver}
|
onDragOver=${handleDragOver}
|
||||||
onDragLeave=${handleDragLeave}
|
onDragLeave=${handleDragLeave}
|
||||||
onDrop=${handleDrop}>
|
onDrop=${handleDrop}>
|
||||||
<span class="folder-tree__toggle" onClick=${handleToggle}>
|
<span class="ext-notes-folder-tree__toggle" onClick=${handleToggle}>
|
||||||
${hasChildren ? (expanded ? '▼' : '▶') : '·'}
|
${hasChildren ? (expanded ? '▼' : '▶') : '·'}
|
||||||
</span>
|
</span>
|
||||||
<span class="folder-tree__icon">${isActive ? '📂' : '📁'}</span>
|
<span class="ext-notes-folder-tree__icon">${isActive ? '📂' : '📁'}</span>
|
||||||
${editing
|
${editing
|
||||||
? html`<input class="folder-tree__edit" value=${editName}
|
? html`<input class="ext-notes-folder-tree__edit" value=${editName}
|
||||||
onInput=${function(e) { setEditName(e.target.value); }}
|
onInput=${function(e) { setEditName(e.target.value); }}
|
||||||
onBlur=${handleRename}
|
onBlur=${handleRename}
|
||||||
onKeyDown=${function(e) { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setEditing(false); }}
|
onKeyDown=${function(e) { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setEditing(false); }}
|
||||||
onClick=${function(e) { e.stopPropagation(); }}
|
onClick=${function(e) { e.stopPropagation(); }}
|
||||||
ref=${function(el) { if (el) el.focus(); }} />`
|
ref=${function(el) { if (el) el.focus(); }} />`
|
||||||
: html`<span class="folder-tree__name">${node.folder.name}</span>`
|
: html`<span class="ext-notes-folder-tree__name">${node.folder.name}</span>`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
${menu && html`<${FolderContextMenu} x=${menu.x} y=${menu.y}
|
${menu && html`<${FolderContextMenu} x=${menu.x} y=${menu.y}
|
||||||
@@ -399,18 +399,18 @@
|
|||||||
var unfiledDrop = makeDropHandlers(setDropUnfiled);
|
var unfiledDrop = makeDropHandlers(setDropUnfiled);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="folder-tree">
|
<div class="ext-notes-folder-tree">
|
||||||
<div class="folder-tree__item ${!activeFolderId && !showUnfiled ? 'folder-tree__item--active' : ''} ${dropAll ? 'folder-tree__item--drop-target' : ''}"
|
<div class="ext-notes-folder-tree__item ${!activeFolderId && !showUnfiled ? 'ext-notes-folder-tree__item--active' : ''} ${dropAll ? 'ext-notes-folder-tree__item--drop-target' : ''}"
|
||||||
onClick=${onSelectAll}
|
onClick=${onSelectAll}
|
||||||
onDragOver=${allDrop.onDragOver} onDragLeave=${allDrop.onDragLeave} onDrop=${allDrop.onDrop}>
|
onDragOver=${allDrop.onDragOver} onDragLeave=${allDrop.onDragLeave} onDrop=${allDrop.onDrop}>
|
||||||
<span class="folder-tree__icon">📋</span>
|
<span class="ext-notes-folder-tree__icon">📋</span>
|
||||||
<span class="folder-tree__name">All Notes</span>
|
<span class="ext-notes-folder-tree__name">All Notes</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="folder-tree__item ${showUnfiled ? 'folder-tree__item--active' : ''} ${dropUnfiled ? 'folder-tree__item--drop-target' : ''}"
|
<div class="ext-notes-folder-tree__item ${showUnfiled ? 'ext-notes-folder-tree__item--active' : ''} ${dropUnfiled ? 'ext-notes-folder-tree__item--drop-target' : ''}"
|
||||||
onClick=${onSelectUnfiled}
|
onClick=${onSelectUnfiled}
|
||||||
onDragOver=${unfiledDrop.onDragOver} onDragLeave=${unfiledDrop.onDragLeave} onDrop=${unfiledDrop.onDrop}>
|
onDragOver=${unfiledDrop.onDragOver} onDragLeave=${unfiledDrop.onDragLeave} onDrop=${unfiledDrop.onDrop}>
|
||||||
<span class="folder-tree__icon">📄</span>
|
<span class="ext-notes-folder-tree__icon">📄</span>
|
||||||
<span class="folder-tree__name">Unfiled</span>
|
<span class="ext-notes-folder-tree__name">Unfiled</span>
|
||||||
</div>
|
</div>
|
||||||
${tree.map(function(node) {
|
${tree.map(function(node) {
|
||||||
return html`<${FolderNode} key=${node.folder.id} node=${node} depth=${0}
|
return html`<${FolderNode} key=${node.folder.id} node=${node} depth=${0}
|
||||||
@@ -419,7 +419,7 @@
|
|||||||
onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder}
|
onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder}
|
||||||
onDropNote=${onDropNote} />`;
|
onDropNote=${onDropNote} />`;
|
||||||
})}
|
})}
|
||||||
<div class="folder-tree__add" onClick=${function() { onCreateFolder(''); }}>
|
<div class="ext-notes-folder-tree__add" onClick=${function() { onCreateFolder(''); }}>
|
||||||
+ New Folder
|
+ New Folder
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -446,18 +446,18 @@
|
|||||||
if (!backlinks.length) return null;
|
if (!backlinks.length) return null;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="backlinks-panel">
|
<div class="ext-notes-backlinks-panel">
|
||||||
<div class="backlinks-panel__header" onClick=${function() { setCollapsed(!collapsed); }}>
|
<div class="ext-notes-backlinks-panel__header" onClick=${function() { setCollapsed(!collapsed); }}>
|
||||||
<span class="backlinks-panel__toggle">${collapsed ? '▶' : '▼'}</span>
|
<span class="ext-notes-backlinks-panel__toggle">${collapsed ? '▶' : '▼'}</span>
|
||||||
<span>Backlinks</span>
|
<span>Backlinks</span>
|
||||||
<span class="backlinks-panel__count">${backlinks.length}</span>
|
<span class="ext-notes-backlinks-panel__count">${backlinks.length}</span>
|
||||||
</div>
|
</div>
|
||||||
${!collapsed && backlinks.map(function(bl) {
|
${!collapsed && backlinks.map(function(bl) {
|
||||||
return html`
|
return html`
|
||||||
<div key=${bl.source_id} class="backlinks-panel__item"
|
<div key=${bl.source_id} class="ext-notes-backlinks-panel__item"
|
||||||
onClick=${function() { if (onNavigate) onNavigate(bl.source_id); }}>
|
onClick=${function() { if (onNavigate) onNavigate(bl.source_id); }}>
|
||||||
<span class="backlinks-panel__title">${bl.source_title}</span>
|
<span class="ext-notes-backlinks-panel__title">${bl.source_title}</span>
|
||||||
<span class="backlinks-panel__context">via [[${bl.link_text}]]</span>
|
<span class="ext-notes-backlinks-panel__context">via [[${bl.link_text}]]</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
@@ -547,10 +547,10 @@
|
|||||||
|
|
||||||
function SidebarTabs({ activeTab, onTabChange, noteSelected }) {
|
function SidebarTabs({ activeTab, onTabChange, noteSelected }) {
|
||||||
return html`
|
return html`
|
||||||
<div class="sidebar-tabs">
|
<div class="ext-ext-notes-sidebar-tabs">
|
||||||
<button class="sidebar-tabs__tab ${activeTab === 'notes' ? 'sidebar-tabs__tab--active' : ''}"
|
<button class="ext-ext-notes-sidebar-tabs__tab ${activeTab === 'notes' ? 'ext-ext-notes-sidebar-tabs__tab--active' : ''}"
|
||||||
onClick=${function() { onTabChange('notes'); }}>Notes</button>
|
onClick=${function() { onTabChange('notes'); }}>Notes</button>
|
||||||
<button class="sidebar-tabs__tab ${activeTab === 'outline' ? 'sidebar-tabs__tab--active' : ''} ${!noteSelected ? 'sidebar-tabs__tab--disabled' : ''}"
|
<button class="ext-ext-notes-sidebar-tabs__tab ${activeTab === 'outline' ? 'ext-ext-notes-sidebar-tabs__tab--active' : ''} ${!noteSelected ? 'ext-ext-notes-sidebar-tabs__tab--disabled' : ''}"
|
||||||
onClick=${function() { if (noteSelected) onTabChange('outline'); }}>Outline</button>
|
onClick=${function() { if (noteSelected) onTabChange('outline'); }}>Outline</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -563,14 +563,14 @@
|
|||||||
|
|
||||||
function SidebarOutline({ headings, activeHeadingIdx, onScrollToHeading }) {
|
function SidebarOutline({ headings, activeHeadingIdx, onScrollToHeading }) {
|
||||||
if (!headings || headings.length === 0) {
|
if (!headings || headings.length === 0) {
|
||||||
return html`<div class="sidebar-outline__empty">No headings found</div>`;
|
return html`<div class="ext-ext-notes-sidebar-outline__empty">No headings found</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="sidebar-outline">
|
<div class="ext-ext-notes-sidebar-outline">
|
||||||
${headings.map(function(h, i) {
|
${headings.map(function(h, i) {
|
||||||
return html`
|
return html`
|
||||||
<div key=${i} class="sidebar-outline__item sidebar-outline__item--h${h.level} ${i === activeHeadingIdx ? 'sidebar-outline__item--active' : ''}"
|
<div key=${i} class="ext-ext-notes-sidebar-outline__item ext-ext-notes-sidebar-outline__item--h${h.level} ${i === activeHeadingIdx ? 'ext-ext-notes-sidebar-outline__item--active' : ''}"
|
||||||
style="padding-left: ${10 + (h.level - 1) * 14}px"
|
style="padding-left: ${10 + (h.level - 1) * 14}px"
|
||||||
onClick=${function() { onScrollToHeading(h); }}>
|
onClick=${function() { onScrollToHeading(h); }}>
|
||||||
${h.text}
|
${h.text}
|
||||||
@@ -772,8 +772,8 @@
|
|||||||
for (var i = 0; i < outgoingLinks.length; i++) {
|
for (var i = 0; i < outgoingLinks.length; i++) {
|
||||||
var link = outgoingLinks[i];
|
var link = outgoingLinks[i];
|
||||||
if (!link.target_id) {
|
if (!link.target_id) {
|
||||||
var search = 'class="wikilink" data-wikilink="' + _escAttr(link.link_text) + '"';
|
var search = 'class="ext-notes-wikilink" data-wikilink="' + _escAttr(link.link_text) + '"';
|
||||||
var replace = 'class="wikilink wikilink--unresolved" data-wikilink="' + _escAttr(link.link_text) + '"';
|
var replace = 'class="ext-notes-wikilink ext-notes-wikilink--unresolved" data-wikilink="' + _escAttr(link.link_text) + '"';
|
||||||
h = h.split(search).join(replace);
|
h = h.split(search).join(replace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -787,7 +787,7 @@
|
|||||||
if (!el || viewMode === 'edit') return;
|
if (!el || viewMode === 'edit') return;
|
||||||
function handleClick(e) {
|
function handleClick(e) {
|
||||||
var target = e.target;
|
var target = e.target;
|
||||||
if (target.classList && target.classList.contains('wikilink')) {
|
if (target.classList && target.classList.contains('ext-notes-wikilink')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var linkText = target.getAttribute('data-wikilink');
|
var linkText = target.getAttribute('data-wikilink');
|
||||||
if (linkText && onNavigateToTitle) onNavigateToTitle(linkText);
|
if (linkText && onNavigateToTitle) onNavigateToTitle(linkText);
|
||||||
@@ -939,44 +939,44 @@
|
|||||||
}, [folders]);
|
}, [folders]);
|
||||||
|
|
||||||
if (!note) {
|
if (!note) {
|
||||||
return html`<div class="notes-editor__empty">Select a note or create a new one</div>`;
|
return html`<div class="ext-notes-editor__empty">Select a note or create a new one</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Render editor body ─────────────────────
|
// ── Render editor body ─────────────────────
|
||||||
function renderEditorBody() {
|
function renderEditorBody() {
|
||||||
if (viewMode === 'rendered') {
|
if (viewMode === 'rendered') {
|
||||||
return html`<div class="notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`;
|
return html`<div class="ext-notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`;
|
||||||
}
|
}
|
||||||
if (viewMode === 'split') {
|
if (viewMode === 'split') {
|
||||||
var editor = hasCM
|
var editor = hasCM
|
||||||
? html`<div class="notes-editor__cm" ref=${cmContainerRef} />`
|
? html`<div class="ext-notes-editor__cm" ref=${cmContainerRef} />`
|
||||||
: html`<textarea class="notes-editor__textarea" ref=${textareaRef}
|
: html`<textarea class="ext-notes-editor__textarea" ref=${textareaRef}
|
||||||
value=${body} onInput=${handleBodyChange}
|
value=${body} onInput=${handleBodyChange}
|
||||||
onKeyDown=${handleKeyDown}
|
onKeyDown=${handleKeyDown}
|
||||||
placeholder="Start writing in Markdown…" />`;
|
placeholder="Start writing in Markdown…" />`;
|
||||||
return [
|
return [
|
||||||
editor,
|
editor,
|
||||||
html`<div class="notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
|
html`<div class="ext-notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
// edit mode
|
// edit mode
|
||||||
if (hasCM) {
|
if (hasCM) {
|
||||||
return html`<div class="notes-editor__cm" ref=${cmContainerRef} />`;
|
return html`<div class="ext-notes-editor__cm" ref=${cmContainerRef} />`;
|
||||||
}
|
}
|
||||||
return html`<textarea class="notes-editor__textarea" ref=${textareaRef}
|
return html`<textarea class="ext-notes-editor__textarea" ref=${textareaRef}
|
||||||
value=${body} onInput=${handleBodyChange}
|
value=${body} onInput=${handleBodyChange}
|
||||||
onKeyDown=${handleKeyDown}
|
onKeyDown=${handleKeyDown}
|
||||||
placeholder="Start writing in Markdown…" />`;
|
placeholder="Start writing in Markdown…" />`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="notes-editor">
|
<div class="ext-notes-editor">
|
||||||
<div class="notes-editor__header">
|
<div class="ext-notes-editor__header">
|
||||||
<input class="notes-editor__title-input" type="text"
|
<input class="ext-notes-editor__title-input" type="text"
|
||||||
value=${title} onInput=${handleTitleChange}
|
value=${title} onInput=${handleTitleChange}
|
||||||
placeholder="Note title" />
|
placeholder="Note title" />
|
||||||
<div class="notes-editor__actions">
|
<div class="ext-notes-editor__actions">
|
||||||
<select class="notes-editor__folder-select"
|
<select class="ext-notes-editor__folder-select"
|
||||||
value=${note.folder_id || ''}
|
value=${note.folder_id || ''}
|
||||||
onChange=${async function(e) {
|
onChange=${async function(e) {
|
||||||
await api.post('/notes/move', { note_id: note.id, folder_id: e.target.value });
|
await api.post('/notes/move', { note_id: note.id, folder_id: e.target.value });
|
||||||
@@ -987,28 +987,28 @@
|
|||||||
return html`<option key=${f.id} value=${f.id}>${f.label}</option>`;
|
return html`<option key=${f.id} value=${f.id}>${f.label}</option>`;
|
||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
<span class=${dirty ? 'notes-saved notes-saved--dirty' : 'notes-saved'}>
|
<span class=${dirty ? 'ext-notes-saved ext-notes-saved--dirty' : 'ext-notes-saved'}>
|
||||||
${saving ? 'Saving…' : (dirty ? 'Unsaved' : 'Saved')}
|
${saving ? 'Saving…' : (dirty ? 'Unsaved' : 'Saved')}
|
||||||
</span>
|
</span>
|
||||||
<button class="notes-toggle" onClick=${function() {
|
<button class="ext-notes-toggle" onClick=${function() {
|
||||||
var next = viewMode === 'rendered' ? 'edit' : viewMode === 'edit' ? 'split' : 'rendered';
|
var next = viewMode === 'rendered' ? 'edit' : viewMode === 'edit' ? 'split' : 'rendered';
|
||||||
_modeStore.set('editor_mode', next);
|
_modeStore.set('editor_mode', next);
|
||||||
setViewMode(next);
|
setViewMode(next);
|
||||||
}}>
|
}}>
|
||||||
${viewMode === 'rendered' ? 'Edit' : viewMode === 'edit' ? 'Split' : 'Read'}
|
${viewMode === 'rendered' ? 'Edit' : viewMode === 'edit' ? 'Split' : 'Read'}
|
||||||
</button>
|
</button>
|
||||||
<button class="notes-btn" onClick=${handleExport} title="Export as .md">Export</button>
|
<button class="ext-notes-btn" onClick=${handleExport} title="Export as .md">Export</button>
|
||||||
<button class="notes-btn" onClick=${handlePin}
|
<button class="ext-notes-btn" onClick=${handlePin}
|
||||||
title=${note.pinned ? 'Unpin' : 'Pin'}>
|
title=${note.pinned ? 'Unpin' : 'Pin'}>
|
||||||
${note.pinned ? '📌' : '📍'}
|
${note.pinned ? '📌' : '📍'}
|
||||||
</button>
|
</button>
|
||||||
<button class="notes-btn notes-btn--danger" onClick=${handleArchive}
|
<button class="ext-notes-btn ext-notes-btn--danger" onClick=${handleArchive}
|
||||||
title="Archive">🗑</button>
|
title="Archive">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
|
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
|
||||||
<div class="notes-editor__content">
|
<div class="ext-notes-editor__content">
|
||||||
<div class="notes-editor__body ${viewMode === 'split' ? 'notes-editor__body--split' : ''}">
|
<div class="ext-notes-editor__body ${viewMode === 'split' ? 'ext-notes-editor__body--split' : ''}">
|
||||||
${renderEditorBody()}
|
${renderEditorBody()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1405,17 +1405,17 @@
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return html`<div class="graph-pane"><div class="graph-empty"><${Spinner} size="md" /></div></div>`;
|
return html`<div class="ext-notes-graph-pane"><div class="ext-notes-graph-empty"><${Spinner} size="md" /></div></div>`;
|
||||||
}
|
}
|
||||||
if (!graphData || graphData.nodes.length === 0) {
|
if (!graphData || graphData.nodes.length === 0) {
|
||||||
return html`<div class="graph-pane"><div class="graph-empty">No notes to graph. Create notes with [[wikilinks]] to see connections.</div></div>`;
|
return html`<div class="ext-notes-graph-pane"><div class="ext-notes-graph-empty">No notes to graph. Create notes with [[wikilinks]] to see connections.</div></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
var adj = graphData.adj;
|
var adj = graphData.adj;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="graph-pane">
|
<div class="ext-notes-graph-pane">
|
||||||
<div class="graph-toolbar">
|
<div class="ext-notes-graph-toolbar">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" checked=${hideOrphans}
|
<input type="checkbox" checked=${hideOrphans}
|
||||||
onChange=${function(e) { setHideOrphans(e.target.checked); }} />
|
onChange=${function(e) { setHideOrphans(e.target.checked); }} />
|
||||||
@@ -1430,14 +1430,14 @@
|
|||||||
onMouseUp=${handleMouseUp}
|
onMouseUp=${handleMouseUp}
|
||||||
onMouseLeave=${handleMouseUp} />
|
onMouseLeave=${handleMouseUp} />
|
||||||
${hoveredNode && html`
|
${hoveredNode && html`
|
||||||
<div class="graph-tooltip" style=${{ left: (mousePos.x + 14) + 'px', top: (mousePos.y - 10) + 'px' }}>
|
<div class="ext-notes-graph-tooltip" style=${{ left: (mousePos.x + 14) + 'px', top: (mousePos.y - 10) + 'px' }}>
|
||||||
<div class="graph-tooltip__title">${hoveredNode.title}</div>
|
<div class="ext-notes-graph-tooltip__title">${hoveredNode.title}</div>
|
||||||
${hoveredNode.tags && hoveredNode.tags.length > 0 && html`
|
${hoveredNode.tags && hoveredNode.tags.length > 0 && html`
|
||||||
<div class="graph-tooltip__tags">
|
<div class="ext-notes-graph-tooltip__tags">
|
||||||
${hoveredNode.tags.map(function(t) { return html`<span class="tag-pill" key=${t}>${t}</span>`; })}
|
${hoveredNode.tags.map(function(t) { return html`<span class="ext-notes-tag-pill" key=${t}>${t}</span>`; })}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
<div class="graph-tooltip__edges">${(adj[hoveredNode.id] ? adj[hoveredNode.id].size : 0)} connections</div>
|
<div class="ext-notes-graph-tooltip__edges">${(adj[hoveredNode.id] ? adj[hoveredNode.id].size : 0)} connections</div>
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
</div>
|
</div>
|
||||||
@@ -1622,7 +1622,7 @@
|
|||||||
setShowUnfiled(true);
|
setShowUnfiled(true);
|
||||||
}
|
}
|
||||||
async function handleCreateFolder(parentId) {
|
async function handleCreateFolder(parentId) {
|
||||||
var name = prompt('Folder name:');
|
var name = await sw.prompt('Folder name:');
|
||||||
if (!name || !name.trim()) return;
|
if (!name || !name.trim()) return;
|
||||||
try {
|
try {
|
||||||
await api.post('/folders', { name: name.trim(), parent_id: parentId || '' });
|
await api.post('/folders', { name: name.trim(), parent_id: parentId || '' });
|
||||||
@@ -1736,8 +1736,8 @@
|
|||||||
<${Button} variant=${showGraph ? 'primary' : 'secondary'} size="sm"
|
<${Button} variant=${showGraph ? 'primary' : 'secondary'} size="sm"
|
||||||
onClick=${function() { setShowGraph(!showGraph); }}>Graph<//>
|
onClick=${function() { setShowGraph(!showGraph); }}>Graph<//>
|
||||||
<//>
|
<//>
|
||||||
<div class="notes-app">
|
<div class="ext-notes-app">
|
||||||
<div class="notes-sidebar">
|
<div class="ext-notes-sidebar">
|
||||||
<${SidebarTabs} activeTab=${sidebarTab} onTabChange=${setSidebarTab} noteSelected=${!!activeNote} />
|
<${SidebarTabs} activeTab=${sidebarTab} onTabChange=${setSidebarTab} noteSelected=${!!activeNote} />
|
||||||
${sidebarTab === 'notes' && html`
|
${sidebarTab === 'notes' && html`
|
||||||
<${FolderTree} folders=${folders}
|
<${FolderTree} folders=${folders}
|
||||||
@@ -1749,14 +1749,14 @@
|
|||||||
onDeleteFolder=${handleDeleteFolder}
|
onDeleteFolder=${handleDeleteFolder}
|
||||||
onDropNote=${handleDropNote} />
|
onDropNote=${handleDropNote} />
|
||||||
<${TagFilter} tags=${allTags} activeTag=${activeTag} onSelect=${handleTagFilter} />
|
<${TagFilter} tags=${allTags} activeTag=${activeTag} onSelect=${handleTagFilter} />
|
||||||
<div class="notes-search">
|
<div class="ext-notes-search">
|
||||||
<input type="text" placeholder="Search notes…"
|
<input type="text" placeholder="Search notes…"
|
||||||
value=${searchTerm} onInput=${handleSearch} />
|
value=${searchTerm} onInput=${handleSearch} />
|
||||||
</div>
|
</div>
|
||||||
<div class="notes-list">
|
<div class="ext-notes-list">
|
||||||
${loading && html`<div class="notes-list__loading"><${Spinner} size="md" /></div>`}
|
${loading && html`<div class="ext-notes-list__loading"><${Spinner} size="md" /></div>`}
|
||||||
${!loading && notes.length === 0 && html`
|
${!loading && notes.length === 0 && html`
|
||||||
<div class="notes-list__empty">
|
<div class="ext-notes-list__empty">
|
||||||
${searchTerm ? 'No matching notes' : (activeTag ? 'No notes with tag "' + activeTag + '"' : 'No notes yet. Click + New Note to start.')}
|
${searchTerm ? 'No matching notes' : (activeTag ? 'No notes with tag "' + activeTag + '"' : 'No notes yet. Click + New Note to start.')}
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* ── Schedules Surface ───────────────────── */
|
/* ── Schedules Surface ───────────────────── */
|
||||||
|
|
||||||
.sched-app {
|
.ext-schedules-app {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -8,53 +8,53 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-header {
|
.ext-schedules-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-header h1 {
|
.ext-schedules-header h1 {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-body {
|
.ext-schedules-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 16px;
|
padding: var(--sp-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: var(--sp-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-count {
|
.ext-schedules-count {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Table ────────────────────────────────── */
|
/* ── Table ────────────────────────────────── */
|
||||||
|
|
||||||
.sched-list__loading,
|
.ext-schedules-list__loading,
|
||||||
.sched-list__empty {
|
.ext-schedules-list__empty {
|
||||||
padding: 40px 16px;
|
padding: var(--sp-10) var(--sp-4);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-table {
|
.ext-schedules-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-table th {
|
.ext-schedules-table th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 8px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -63,19 +63,19 @@
|
|||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-table td {
|
.ext-schedules-table td {
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-row:hover {
|
.ext-schedules-row:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-row__name { min-width: 160px; }
|
.ext-schedules-row__name { min-width: 160px; }
|
||||||
|
|
||||||
.sched-link {
|
.ext-schedules-link {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
@@ -85,86 +85,86 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.sched-link:hover { text-decoration: underline; }
|
.ext-schedules-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.sched-row__desc {
|
.ext-schedules-row__desc {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-row__cron { white-space: nowrap; }
|
.ext-schedules-row__cron { white-space: nowrap; }
|
||||||
|
|
||||||
.sched-cron-badge {
|
.ext-schedules-cron-badge {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
padding: 2px 6px;
|
padding: 2px var(--sp-2);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-cron-human {
|
.ext-schedules-cron-human {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-row__next {
|
.ext-schedules-row__next {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-row__actions {
|
.ext-schedules-row__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Toggle button ────────────────────────── */
|
/* ── Toggle button ────────────────────────── */
|
||||||
|
|
||||||
.sched-toggle-btn {
|
.ext-schedules-toggle-btn {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 10px;
|
padding: 2px var(--sp-3);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-toggle-btn--on {
|
.ext-schedules-toggle-btn--on {
|
||||||
background: var(--success);
|
background: var(--success);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-color: var(--success);
|
border-color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-toggle-btn--off {
|
.ext-schedules-toggle-btn--off {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-toggle-btn:hover { opacity: 0.85; }
|
.ext-schedules-toggle-btn:hover { opacity: 0.85; }
|
||||||
|
|
||||||
/* ── Action buttons ───────────────────────── */
|
/* ── Action buttons ───────────────────────── */
|
||||||
|
|
||||||
.sched-btn {
|
.ext-schedules-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.sched-btn:hover { background: var(--bg-hover); }
|
.ext-schedules-btn:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
.sched-btn--sm {
|
.ext-schedules-btn--sm {
|
||||||
padding: 3px 8px;
|
padding: 3px var(--sp-2);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-btn--danger:hover {
|
.ext-schedules-btn--danger:hover {
|
||||||
background: var(--danger-dim);
|
background: var(--danger-dim);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
@@ -172,92 +172,92 @@
|
|||||||
|
|
||||||
/* ── Form ─────────────────────────────────── */
|
/* ── Form ─────────────────────────────────── */
|
||||||
|
|
||||||
.sched-form { display: flex; flex-direction: column; gap: 12px; }
|
.ext-schedules-form { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
.sched-form__row { display: flex; align-items: center; gap: 12px; }
|
.ext-schedules-form__row { display: flex; align-items: center; gap: var(--sp-3); }
|
||||||
.sched-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
|
.ext-schedules-form__actions { display: flex; justify-content: flex-end; gap: var(--sp-2); margin-top: var(--sp-2); }
|
||||||
|
|
||||||
.sched-cron-input {
|
.ext-schedules-cron-input {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-cron-preview {
|
.ext-schedules-cron-preview {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
margin-top: 4px;
|
margin-top: var(--sp-1);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-script {
|
.ext-schedules-script {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-toggle {
|
.ext-schedules-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Logs panel ───────────────────────────── */
|
/* ── Logs panel ───────────────────────────── */
|
||||||
|
|
||||||
.sched-logs {
|
.ext-schedules-logs {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-logs__header {
|
.ext-schedules-logs__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 8px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-logs__loading,
|
.ext-schedules-logs__loading,
|
||||||
.sched-logs__empty {
|
.ext-schedules-logs__empty {
|
||||||
padding: 20px 12px;
|
padding: var(--sp-5) var(--sp-3);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-log-entry {
|
.ext-schedules-log-entry {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
padding: 6px 12px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
.sched-log-entry:last-child { border-bottom: none; }
|
.ext-schedules-log-entry:last-child { border-bottom: none; }
|
||||||
|
|
||||||
.sched-log-entry__status {
|
.ext-schedules-log-entry__status {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.sched-log-entry__status--ok { color: var(--success); }
|
.ext-schedules-log-entry__status--ok { color: var(--success); }
|
||||||
.sched-log-entry__status--fail { color: var(--danger); }
|
.ext-schedules-log-entry__status--fail { color: var(--danger); }
|
||||||
|
|
||||||
.sched-log-entry__time {
|
.ext-schedules-log-entry__time {
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-log-entry__duration {
|
.ext-schedules-log-entry__duration {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sched-log-entry__error {
|
.ext-schedules-log-entry__error {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -269,9 +269,9 @@
|
|||||||
/* ── Responsive ───────────────────────────── */
|
/* ── Responsive ───────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sched-table th:nth-child(3),
|
.ext-schedules-table th:nth-child(3),
|
||||||
.sched-table td:nth-child(3) {
|
.ext-schedules-table td:nth-child(3) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.sched-body { padding: 8px; }
|
.ext-schedules-body { padding: var(--sp-2); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,7 +148,7 @@
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<${Dialog} open onClose=${onClose} title=${schedule ? 'Edit Schedule' : 'New Schedule'}>
|
<${Dialog} open onClose=${onClose} title=${schedule ? 'Edit Schedule' : 'New Schedule'}>
|
||||||
<form onSubmit=${handleSubmit} class="sched-form">
|
<form onSubmit=${handleSubmit} class="ext-schedules-form">
|
||||||
${error && html`<${Banner} variant="danger" text=${error} />`}
|
${error && html`<${Banner} variant="danger" text=${error} />`}
|
||||||
<${FormField} label="Name" required>
|
<${FormField} label="Name" required>
|
||||||
<input type="text" value=${name} onInput=${e => setName(e.target.value)}
|
<input type="text" value=${name} onInput=${e => setName(e.target.value)}
|
||||||
@@ -160,22 +160,22 @@
|
|||||||
<//>
|
<//>
|
||||||
<${FormField} label="Cron Expression" required>
|
<${FormField} label="Cron Expression" required>
|
||||||
<input type="text" value=${cron} onInput=${e => setCron(e.target.value)}
|
<input type="text" value=${cron} onInput=${e => setCron(e.target.value)}
|
||||||
placeholder="e.g. 0 9 * * 1-5" class="sched-cron-input" />
|
placeholder="e.g. 0 9 * * 1-5" class="ext-schedules-cron-input" />
|
||||||
${preview && preview !== cron && html`
|
${preview && preview !== cron && html`
|
||||||
<div class="sched-cron-preview">${preview}</div>
|
<div class="ext-schedules-cron-preview">${preview}</div>
|
||||||
`}
|
`}
|
||||||
<//>
|
<//>
|
||||||
<${FormField} label="Script">
|
<${FormField} label="Script">
|
||||||
<textarea rows="6" value=${script} onInput=${e => setScript(e.target.value)}
|
<textarea rows="6" value=${script} onInput=${e => setScript(e.target.value)}
|
||||||
placeholder="Starlark script (optional)" class="sched-script" />
|
placeholder="Starlark script (optional)" class="ext-schedules-script" />
|
||||||
<//>
|
<//>
|
||||||
<div class="sched-form__row">
|
<div class="ext-schedules-form__row">
|
||||||
<label class="sched-toggle">
|
<label class="ext-schedules-toggle">
|
||||||
<input type="checkbox" checked=${enabled} onChange=${e => setEnabled(e.target.checked)} />
|
<input type="checkbox" checked=${enabled} onChange=${e => setEnabled(e.target.checked)} />
|
||||||
<span>Enabled</span>
|
<span>Enabled</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="sched-form__actions">
|
<div class="ext-schedules-form__actions">
|
||||||
<${Button} variant="ghost" type="button" onClick=${onClose}>Cancel<//>
|
<${Button} variant="ghost" type="button" onClick=${onClose}>Cancel<//>
|
||||||
<${Button} variant="primary" type="submit" disabled=${saving}>
|
<${Button} variant="primary" type="submit" disabled=${saving}>
|
||||||
${saving ? 'Saving…' : (schedule ? 'Update' : 'Create')}
|
${saving ? 'Saving…' : (schedule ? 'Update' : 'Create')}
|
||||||
@@ -208,25 +208,25 @@
|
|||||||
}, [scheduleId]);
|
}, [scheduleId]);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="sched-logs">
|
<div class="ext-schedules-logs">
|
||||||
<div class="sched-logs__header">
|
<div class="ext-schedules-logs__header">
|
||||||
<span>Execution History</span>
|
<span>Execution History</span>
|
||||||
<button class="sched-btn sched-btn--sm" onClick=${onClose}>Close</button>
|
<button class="ext-schedules-btn ext-schedules-btn--sm" onClick=${onClose}>Close</button>
|
||||||
</div>
|
</div>
|
||||||
${loading && html`<div class="sched-logs__loading"><${Spinner} size="sm" /></div>`}
|
${loading && html`<div class="ext-schedules-logs__loading"><${Spinner} size="sm" /></div>`}
|
||||||
${!loading && logs.length === 0 && html`
|
${!loading && logs.length === 0 && html`
|
||||||
<div class="sched-logs__empty">No executions yet</div>
|
<div class="ext-schedules-logs__empty">No executions yet</div>
|
||||||
`}
|
`}
|
||||||
${!loading && logs.map(function (log) {
|
${!loading && logs.map(function (log) {
|
||||||
var success = log.status === 'success' || log.status === 'ok';
|
var success = log.status === 'success' || log.status === 'ok';
|
||||||
return html`
|
return html`
|
||||||
<div class="sched-log-entry" key=${log.id || log.started_at}>
|
<div class="ext-schedules-log-entry" key=${log.id || log.started_at}>
|
||||||
<span class="sched-log-entry__status sched-log-entry__status--${success ? 'ok' : 'fail'}">
|
<span class="ext-schedules-log-entry__status ext-schedules-log-entry__status--${success ? 'ok' : 'fail'}">
|
||||||
${success ? '✓' : '✗'}
|
${success ? '✓' : '✗'}
|
||||||
</span>
|
</span>
|
||||||
<span class="sched-log-entry__time">${_formatTime(log.started_at)}</span>
|
<span class="ext-schedules-log-entry__time">${_formatTime(log.started_at)}</span>
|
||||||
<span class="sched-log-entry__duration">${_formatDuration(log.duration_ms)}</span>
|
<span class="ext-schedules-log-entry__duration">${_formatDuration(log.duration_ms)}</span>
|
||||||
${log.error && html`<span class="sched-log-entry__error" title=${log.error}>${log.error}</span>`}
|
${log.error && html`<span class="ext-schedules-log-entry__error" title=${log.error}>${log.error}</span>`}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
@@ -286,13 +286,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="sched-list">
|
<div class="ext-schedules-list">
|
||||||
${loading && html`<div class="sched-list__loading"><${Spinner} size="md" /></div>`}
|
${loading && html`<div class="ext-schedules-list__loading"><${Spinner} size="md" /></div>`}
|
||||||
${!loading && items.length === 0 && html`
|
${!loading && items.length === 0 && html`
|
||||||
<div class="sched-list__empty">No schedules yet. Create one to get started.</div>
|
<div class="ext-schedules-list__empty">No schedules yet. Create one to get started.</div>
|
||||||
`}
|
`}
|
||||||
${!loading && items.length > 0 && html`
|
${!loading && items.length > 0 && html`
|
||||||
<table class="sched-table">
|
<table class="ext-schedules-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
@@ -305,27 +305,27 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
${items.map(function (s) {
|
${items.map(function (s) {
|
||||||
return html`
|
return html`
|
||||||
<tr key=${s.id} class="sched-row">
|
<tr key=${s.id} class="ext-schedules-row">
|
||||||
<td class="sched-row__name">
|
<td class="ext-schedules-row__name">
|
||||||
<button class="sched-link" onClick=${() => onEdit(s)}>${s.name}</button>
|
<button class="ext-schedules-link" onClick=${() => onEdit(s)}>${s.name}</button>
|
||||||
${s.description && html`<div class="sched-row__desc">${s.description}</div>`}
|
${s.description && html`<div class="ext-schedules-row__desc">${s.description}</div>`}
|
||||||
</td>
|
</td>
|
||||||
<td class="sched-row__cron">
|
<td class="ext-schedules-row__cron">
|
||||||
<span class="sched-cron-badge">${s.cron_expr}</span>
|
<span class="ext-schedules-cron-badge">${s.cron_expr}</span>
|
||||||
<div class="sched-cron-human">${describeCron(s.cron_expr)}</div>
|
<div class="ext-schedules-cron-human">${describeCron(s.cron_expr)}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="sched-row__next">${s.next_fire_at ? _formatTime(s.next_fire_at) : '—'}</td>
|
<td class="ext-schedules-row__next">${s.next_fire_at ? _formatTime(s.next_fire_at) : '—'}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="sched-toggle-btn sched-toggle-btn--${s.enabled ? 'on' : 'off'}"
|
<button class="ext-schedules-toggle-btn ext-schedules-toggle-btn--${s.enabled ? 'on' : 'off'}"
|
||||||
onClick=${() => handleToggle(s)}
|
onClick=${() => handleToggle(s)}
|
||||||
title=${s.enabled ? 'Disable' : 'Enable'}>
|
title=${s.enabled ? 'Disable' : 'Enable'}>
|
||||||
${s.enabled ? 'On' : 'Off'}
|
${s.enabled ? 'On' : 'Off'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="sched-row__actions">
|
<td class="ext-schedules-row__actions">
|
||||||
<button class="sched-btn sched-btn--sm" onClick=${() => handleRun(s)} title="Run now">▶</button>
|
<button class="ext-schedules-btn ext-schedules-btn--sm" onClick=${() => handleRun(s)} title="Run now">▶</button>
|
||||||
<button class="sched-btn sched-btn--sm" onClick=${() => onShowLogs(s.id)} title="View logs">📋</button>
|
<button class="ext-schedules-btn ext-schedules-btn--sm" onClick=${() => onShowLogs(s.id)} title="View logs">📋</button>
|
||||||
<button class="sched-btn sched-btn--sm sched-btn--danger" onClick=${() => handleDelete(s)}
|
<button class="ext-schedules-btn ext-schedules-btn--sm ext-schedules-btn--danger" onClick=${() => handleDelete(s)}
|
||||||
title="Delete">×</button>
|
title="Delete">×</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -371,14 +371,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="sched-app">
|
<div class="ext-schedules-app">
|
||||||
${Topbar ? html`
|
${Topbar ? html`
|
||||||
<${Topbar} title="Schedules">
|
<${Topbar} title="Schedules">
|
||||||
<span class="sched-count">${items.length} schedule${items.length !== 1 ? 's' : ''}</span>
|
<span class="ext-schedules-count">${items.length} schedule${items.length !== 1 ? 's' : ''}</span>
|
||||||
<${Button} variant="primary" size="sm" onClick=${() => setEditing({})}>+ New Schedule<//>
|
<${Button} variant="primary" size="sm" onClick=${() => setEditing({})}>+ New Schedule<//>
|
||||||
<//>
|
<//>
|
||||||
` : html`
|
` : html`
|
||||||
<div class="sched-header">
|
<div class="ext-schedules-header">
|
||||||
<h1>Schedules</h1>
|
<h1>Schedules</h1>
|
||||||
<${Button} variant="primary" size="sm" onClick=${() => setEditing({})}>+ New Schedule<//>
|
<${Button} variant="primary" size="sm" onClick=${() => setEditing({})}>+ New Schedule<//>
|
||||||
</div>
|
</div>
|
||||||
@@ -386,7 +386,7 @@
|
|||||||
|
|
||||||
${error && html`<${Banner} variant="danger" text=${error} />`}
|
${error && html`<${Banner} variant="danger" text=${error} />`}
|
||||||
|
|
||||||
<div class="sched-body">
|
<div class="ext-schedules-body">
|
||||||
<${ScheduleList}
|
<${ScheduleList}
|
||||||
items=${items}
|
items=${items}
|
||||||
loading=${loading}
|
loading=${loading}
|
||||||
|
|||||||
@@ -1,222 +1,222 @@
|
|||||||
/* SDK Test Runner — Styles */
|
/* SDK Test Runner — Styles */
|
||||||
|
|
||||||
.sdkr-header {
|
.ext-sdk-test-runner-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
margin-bottom: 4px;
|
margin-bottom: var(--sp-1);
|
||||||
}
|
}
|
||||||
.sdkr-header h1 {
|
.ext-sdk-test-runner-header h1 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--text, #e0e0e0);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.sdkr-version {
|
.ext-sdk-test-runner-version {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text3, #888);
|
color: var(--text-3);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
.sdkr-desc {
|
.ext-sdk-test-runner-desc {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text2, #aaa);
|
color: var(--text-2);
|
||||||
margin: 0 0 16px 0;
|
margin: 0 0 var(--sp-4) 0;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fixtures panel */
|
/* Fixtures panel */
|
||||||
.sdkr-fixtures {
|
.ext-sdk-test-runner-fixtures {
|
||||||
margin-bottom: 16px;
|
margin-bottom: var(--sp-4);
|
||||||
padding: 10px 14px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
border: 1px solid var(--border, #333);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
background: var(--bg1, #1a1a1a);
|
background: var(--bg-raised);
|
||||||
}
|
}
|
||||||
.sdkr-fixture-row {
|
.ext-sdk-test-runner-fixture-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.sdkr-fixture-label {
|
.ext-sdk-test-runner-fixture-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text2, #aaa);
|
color: var(--text-2);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.sdkr-select {
|
.ext-sdk-test-runner-select {
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
background: var(--bg2, #222);
|
background: var(--bg-raised);
|
||||||
color: var(--text, #e0e0e0);
|
color: var(--text);
|
||||||
border: 1px solid var(--border, #444);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
min-width: 130px;
|
min-width: 130px;
|
||||||
}
|
}
|
||||||
.sdkr-input {
|
.ext-sdk-test-runner-input {
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
background: var(--bg2, #222);
|
background: var(--bg-raised);
|
||||||
color: var(--text, #e0e0e0);
|
color: var(--text);
|
||||||
border: 1px solid var(--border, #444);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
.sdkr-input::placeholder {
|
.ext-sdk-test-runner-input::placeholder {
|
||||||
color: var(--text3, #666);
|
color: var(--text-3);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.sdkr-fixture-status {
|
.ext-sdk-test-runner-fixture-status {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text3, #888);
|
color: var(--text-3);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
.sdkr-fixture-note {
|
.ext-sdk-test-runner-fixture-note {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text3, #666);
|
color: var(--text-3);
|
||||||
margin-top: 6px;
|
margin-top: var(--sp-2);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Controls */
|
/* Controls */
|
||||||
.sdkr-controls {
|
.ext-sdk-test-runner-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
margin-bottom: 16px;
|
margin-bottom: var(--sp-4);
|
||||||
}
|
}
|
||||||
.sdkr-filters {
|
.ext-sdk-test-runner-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
.sdkr-filter-label {
|
.ext-sdk-test-runner-filter-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text2, #aaa);
|
color: var(--text-2);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
.sdkr-check {
|
.ext-sdk-test-runner-check {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text, #e0e0e0);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
.sdkr-check input { cursor: pointer; }
|
.ext-sdk-test-runner-check input { cursor: pointer; }
|
||||||
.sdkr-btn-row {
|
.ext-sdk-test-runner-btn-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
.sdkr-btn {
|
.ext-sdk-test-runner-btn {
|
||||||
padding: 6px 14px;
|
padding: var(--sp-2) var(--sp-4);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--border, #444);
|
border: 1px solid var(--border);
|
||||||
background: var(--bg2, #2a2a2a);
|
background: var(--bg-raised);
|
||||||
color: var(--text, #e0e0e0);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.sdkr-btn:hover { background: var(--bg3, #383838); }
|
.ext-sdk-test-runner-btn:hover { background: var(--bg-hover); }
|
||||||
.sdkr-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.ext-sdk-test-runner-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.sdkr-btn-primary {
|
.ext-sdk-test-runner-btn-primary {
|
||||||
background: var(--accent, #3b82f6);
|
background: var(--accent);
|
||||||
border-color: var(--accent, #3b82f6);
|
border-color: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
.sdkr-btn-primary:hover { filter: brightness(1.15); }
|
.ext-sdk-test-runner-btn-primary:hover { filter: brightness(1.15); }
|
||||||
.sdkr-btn-danger {
|
.ext-sdk-test-runner-btn-danger {
|
||||||
background: var(--danger, #ef4444);
|
background: var(--danger);
|
||||||
border-color: var(--danger, #ef4444);
|
border-color: var(--danger);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Summary */
|
/* Summary */
|
||||||
.sdkr-summary {
|
.ext-sdk-test-runner-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--sp-3);
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
background: var(--bg2, #1e1e1e);
|
background: var(--bg-raised);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--border, #333);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.sdkr-badge {
|
.ext-sdk-test-runner-badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 3px 10px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Table */
|
/* Table */
|
||||||
.sdkr-table-wrap {
|
.ext-sdk-test-runner-table-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border: 1px solid var(--border, #333);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.sdkr-table {
|
.ext-sdk-test-runner-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.sdkr-table th {
|
.ext-sdk-test-runner-table th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 8px 10px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
background: var(--bg2, #1e1e1e);
|
background: var(--bg-raised);
|
||||||
color: var(--text2, #aaa);
|
color: var(--text-2);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
border-bottom: 1px solid var(--border, #333);
|
border-bottom: 1px solid var(--border);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.sdkr-table td {
|
.ext-sdk-test-runner-table td {
|
||||||
padding: 6px 10px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border-subtle, #2a2a2a);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
color: var(--text, #e0e0e0);
|
color: var(--text);
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
.sdkr-table tbody tr:hover {
|
.ext-sdk-test-runner-table tbody tr:hover {
|
||||||
background: var(--bg-hover, rgba(255,255,255,0.03));
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
.sdkr-test-name {
|
.ext-sdk-test-runner-test-name {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.sdkr-verdict {
|
.ext-sdk-test-runner-verdict {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.sdkr-ms {
|
.ext-sdk-test-runner-ms {
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text3, #888);
|
color: var(--text-3);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
.sdkr-detail {
|
.ext-sdk-test-runner-detail {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text3, #888);
|
color: var(--text-3);
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Row verdict highlights */
|
/* Row verdict highlights */
|
||||||
.sdkr-row-sdk_bug td { background: rgba(245, 158, 11, 0.06); }
|
.ext-sdk-test-runner-row-sdk_bug td { background: rgba(245, 158, 11, 0.06); }
|
||||||
.sdkr-row-icd_bug td { background: rgba(239, 68, 68, 0.06); }
|
.ext-sdk-test-runner-row-icd_bug td { background: rgba(239, 68, 68, 0.06); }
|
||||||
.sdkr-row-shape_bug td { background: rgba(168, 85, 247, 0.06); }
|
.ext-sdk-test-runner-row-shape_bug td { background: rgba(168, 85, 247, 0.06); }
|
||||||
.sdkr-row-error td { background: rgba(239, 68, 68, 0.08); }
|
.ext-sdk-test-runner-row-error td { background: rgba(239, 68, 68, 0.08); }
|
||||||
|
|||||||
@@ -58,25 +58,25 @@
|
|||||||
mount.innerHTML = '';
|
mount.innerHTML = '';
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
var header = el('div', { className: 'sdkr-header' }, [
|
var header = el('div', { className: 'ext-sdk-test-runner-header' }, [
|
||||||
el('h1', {}, 'SDK Test Runner'),
|
el('h1', {}, 'SDK Test Runner'),
|
||||||
el('span', { className: 'sdkr-version' }, 'v' + (T.manifest.version || '?'))
|
el('span', { className: 'ext-sdk-test-runner-version' }, 'v' + (T.manifest.version || '?'))
|
||||||
]);
|
]);
|
||||||
mount.appendChild(header);
|
mount.appendChild(header);
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
mount.appendChild(el('p', { className: 'sdkr-desc' },
|
mount.appendChild(el('p', { className: 'ext-sdk-test-runner-desc' },
|
||||||
'Dual-path validation: SDK call → raw ICD fetch. ' +
|
'Dual-path validation: SDK call → raw ICD fetch. ' +
|
||||||
'SDK fail + ICD pass = SDK bug. Both fail = ICD bug.'));
|
'SDK fail + ICD pass = SDK bug. Both fail = ICD bug.'));
|
||||||
|
|
||||||
// ── Fixtures Panel ──
|
// ── Fixtures Panel ──
|
||||||
var fixturePanel = el('div', { className: 'sdkr-fixtures' });
|
var fixturePanel = el('div', { className: 'ext-sdk-test-runner-fixtures' });
|
||||||
|
|
||||||
var fixRow = el('div', { className: 'sdkr-fixture-row' });
|
var fixRow = el('div', { className: 'ext-sdk-test-runner-fixture-row' });
|
||||||
fixRow.appendChild(el('span', { className: 'sdkr-fixture-label' }, 'FIXTURES:'));
|
fixRow.appendChild(el('span', { className: 'ext-sdk-test-runner-fixture-label' }, 'FIXTURES:'));
|
||||||
|
|
||||||
// Provider dropdown
|
// Provider dropdown
|
||||||
var provSelect = el('select', { className: 'sdkr-select' });
|
var provSelect = el('select', { className: 'ext-sdk-test-runner-select' });
|
||||||
provSelect.appendChild(el('option', { value: '' }, '— provider —'));
|
provSelect.appendChild(el('option', { value: '' }, '— provider —'));
|
||||||
['openrouter', 'openai', 'anthropic', 'venice'].forEach(function (p) {
|
['openrouter', 'openai', 'anthropic', 'venice'].forEach(function (p) {
|
||||||
provSelect.appendChild(el('option', { value: p }, p));
|
provSelect.appendChild(el('option', { value: p }, p));
|
||||||
@@ -85,18 +85,18 @@
|
|||||||
|
|
||||||
// API key input
|
// API key input
|
||||||
var keyInput = el('input', {
|
var keyInput = el('input', {
|
||||||
type: 'password', className: 'sdkr-input',
|
type: 'password', className: 'ext-sdk-test-runner-input',
|
||||||
placeholder: 'API key (optional — enables model tests)'
|
placeholder: 'API key (optional — enables model tests)'
|
||||||
});
|
});
|
||||||
fixRow.appendChild(keyInput);
|
fixRow.appendChild(keyInput);
|
||||||
|
|
||||||
// Fixture status
|
// Fixture status
|
||||||
var fixStatus = el('span', { className: 'sdkr-fixture-status' }, '');
|
var fixStatus = el('span', { className: 'ext-sdk-test-runner-fixture-status' }, '');
|
||||||
|
|
||||||
fixRow.appendChild(fixStatus);
|
fixRow.appendChild(fixStatus);
|
||||||
fixturePanel.appendChild(fixRow);
|
fixturePanel.appendChild(fixRow);
|
||||||
|
|
||||||
var fixNote = el('div', { className: 'sdkr-fixture-note' },
|
var fixNote = el('div', { className: 'ext-sdk-test-runner-fixture-note' },
|
||||||
'Provide a provider + API key to unlock embedding/model tests. ' +
|
'Provide a provider + API key to unlock embedding/model tests. ' +
|
||||||
'Without a key, those tests are skipped. Key is never stored.');
|
'Without a key, those tests are skipped. Key is never stored.');
|
||||||
fixturePanel.appendChild(fixNote);
|
fixturePanel.appendChild(fixNote);
|
||||||
@@ -107,16 +107,16 @@
|
|||||||
T._fixtureInput = { provSelect: provSelect, keyInput: keyInput, statusEl: fixStatus };
|
T._fixtureInput = { provSelect: provSelect, keyInput: keyInput, statusEl: fixStatus };
|
||||||
|
|
||||||
// Controls row
|
// Controls row
|
||||||
var controlRow = el('div', { className: 'sdkr-controls' });
|
var controlRow = el('div', { className: 'ext-sdk-test-runner-controls' });
|
||||||
|
|
||||||
// Domain filter checkboxes
|
// Domain filter checkboxes
|
||||||
var filterBox = el('div', { className: 'sdkr-filters' });
|
var filterBox = el('div', { className: 'ext-sdk-test-runner-filters' });
|
||||||
filterBox.appendChild(el('span', { className: 'sdkr-filter-label' }, 'Domains: '));
|
filterBox.appendChild(el('span', { className: 'ext-sdk-test-runner-filter-label' }, 'Domains: '));
|
||||||
var domainNames = Object.keys(T.domains).sort();
|
var domainNames = Object.keys(T.domains).sort();
|
||||||
var domainChecks = {};
|
var domainChecks = {};
|
||||||
|
|
||||||
domainNames.forEach(function (name) {
|
domainNames.forEach(function (name) {
|
||||||
var label = el('label', { className: 'sdkr-check' });
|
var label = el('label', { className: 'ext-sdk-test-runner-check' });
|
||||||
var cb = el('input', { type: 'checkbox', checked: 'checked' });
|
var cb = el('input', { type: 'checkbox', checked: 'checked' });
|
||||||
domainChecks[name] = cb;
|
domainChecks[name] = cb;
|
||||||
label.appendChild(cb);
|
label.appendChild(cb);
|
||||||
@@ -126,11 +126,11 @@
|
|||||||
controlRow.appendChild(filterBox);
|
controlRow.appendChild(filterBox);
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
var btnRow = el('div', { className: 'sdkr-btn-row' });
|
var btnRow = el('div', { className: 'ext-sdk-test-runner-btn-row' });
|
||||||
var runBtn = el('button', { className: 'sdkr-btn sdkr-btn-primary', onClick: onRun }, 'Run All');
|
var runBtn = el('button', { className: 'ext-sdk-test-runner-btn ext-sdk-test-runner-btn-primary', onClick: onRun }, 'Run All');
|
||||||
var abortBtn = el('button', { className: 'sdkr-btn sdkr-btn-danger', onClick: function () { T.abort(); } }, 'Abort');
|
var abortBtn = el('button', { className: 'ext-sdk-test-runner-btn ext-sdk-test-runner-btn-danger', onClick: function () { T.abort(); } }, 'Abort');
|
||||||
abortBtn.style.display = 'none';
|
abortBtn.style.display = 'none';
|
||||||
var exportBtn = el('button', { className: 'sdkr-btn', onClick: onExport }, 'Export JSON');
|
var exportBtn = el('button', { className: 'ext-sdk-test-runner-btn', onClick: onExport }, 'Export JSON');
|
||||||
btnRow.appendChild(runBtn);
|
btnRow.appendChild(runBtn);
|
||||||
btnRow.appendChild(abortBtn);
|
btnRow.appendChild(abortBtn);
|
||||||
btnRow.appendChild(exportBtn);
|
btnRow.appendChild(exportBtn);
|
||||||
@@ -138,12 +138,12 @@
|
|||||||
mount.appendChild(controlRow);
|
mount.appendChild(controlRow);
|
||||||
|
|
||||||
// Summary bar
|
// Summary bar
|
||||||
var summaryBar = el('div', { className: 'sdkr-summary' });
|
var summaryBar = el('div', { className: 'ext-sdk-test-runner-summary' });
|
||||||
mount.appendChild(summaryBar);
|
mount.appendChild(summaryBar);
|
||||||
|
|
||||||
// Results table
|
// Results table
|
||||||
var tableWrap = el('div', { className: 'sdkr-table-wrap' });
|
var tableWrap = el('div', { className: 'ext-sdk-test-runner-table-wrap' });
|
||||||
var table = el('table', { className: 'sdkr-table' });
|
var table = el('table', { className: 'ext-sdk-test-runner-table' });
|
||||||
var thead = el('thead', {}, [
|
var thead = el('thead', {}, [
|
||||||
el('tr', {}, [
|
el('tr', {}, [
|
||||||
el('th', {}, '#'),
|
el('th', {}, '#'),
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
];
|
];
|
||||||
items.forEach(function (item) {
|
items.forEach(function (item) {
|
||||||
var badge = el('span', {
|
var badge = el('span', {
|
||||||
className: 'sdkr-badge',
|
className: 'ext-sdk-test-runner-badge',
|
||||||
style: {
|
style: {
|
||||||
background: item[1] > 0 ? COLORS[item[0]] : 'var(--bg2, #333)',
|
background: item[1] > 0 ? COLORS[item[0]] : 'var(--bg2, #333)',
|
||||||
color: item[1] > 0 ? '#fff' : 'var(--text3, #888)'
|
color: item[1] > 0 ? '#fff' : 'var(--text3, #888)'
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
}, LABELS[item[0]] + ': ' + item[1]);
|
}, LABELS[item[0]] + ': ' + item[1]);
|
||||||
summaryBar.appendChild(badge);
|
summaryBar.appendChild(badge);
|
||||||
});
|
});
|
||||||
summaryBar.appendChild(el('span', { className: 'sdkr-badge',
|
summaryBar.appendChild(el('span', { className: 'ext-sdk-test-runner-badge',
|
||||||
style: { background: 'var(--bg2, #333)' }
|
style: { background: 'var(--bg2, #333)' }
|
||||||
}, 'Total: ' + total));
|
}, 'Total: ' + total));
|
||||||
}
|
}
|
||||||
@@ -197,19 +197,19 @@
|
|||||||
if (entry.rawErr) detail += (detail ? ' | ' : '') + 'ICD: ' + esc(entry.rawErr);
|
if (entry.rawErr) detail += (detail ? ' | ' : '') + 'ICD: ' + esc(entry.rawErr);
|
||||||
if (entry.note) detail += (detail ? ' | ' : '') + esc(entry.note);
|
if (entry.note) detail += (detail ? ' | ' : '') + esc(entry.note);
|
||||||
|
|
||||||
var row = el('tr', { className: 'sdkr-row sdkr-row-' + v.toLowerCase() }, [
|
var row = el('tr', { className: 'ext-sdk-test-runner-row ext-sdk-test-runner-row-' + v.toLowerCase() }, [
|
||||||
el('td', {}, String(idx)),
|
el('td', {}, String(idx)),
|
||||||
el('td', {}, esc(entry.domain)),
|
el('td', {}, esc(entry.domain)),
|
||||||
el('td', {}, esc(entry.group)),
|
el('td', {}, esc(entry.group)),
|
||||||
el('td', { className: 'sdkr-test-name' }, esc(entry.name)),
|
el('td', { className: 'ext-sdk-test-runner-test-name' }, esc(entry.name)),
|
||||||
el('td', {}, [
|
el('td', {}, [
|
||||||
el('span', {
|
el('span', {
|
||||||
className: 'sdkr-verdict',
|
className: 'ext-sdk-test-runner-verdict',
|
||||||
style: { color: COLORS[v] || '#fff' }
|
style: { color: COLORS[v] || '#fff' }
|
||||||
}, LABELS[v] || v)
|
}, LABELS[v] || v)
|
||||||
]),
|
]),
|
||||||
el('td', { className: 'sdkr-ms' }, String(entry.durationMs)),
|
el('td', { className: 'ext-sdk-test-runner-ms' }, String(entry.durationMs)),
|
||||||
el('td', { className: 'sdkr-detail' }, detail)
|
el('td', { className: 'ext-sdk-test-runner-detail' }, detail)
|
||||||
]);
|
]);
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
updateSummary();
|
updateSummary();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/* ── Tasks Surface Styles ────────────────── */
|
/* ── Tasks Surface Styles ────────────────── */
|
||||||
|
|
||||||
.tasks-app {
|
.ext-tasks-app {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 24px 20px;
|
padding: var(--sp-6) var(--sp-5);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -11,37 +11,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header ──────────────────────────────── */
|
/* ── Header ──────────────────────────────── */
|
||||||
.tasks-header {
|
.ext-tasks-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
margin-bottom: 20px;
|
margin-bottom: var(--sp-5);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.tasks-title {
|
.ext-tasks-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.tasks-stats {
|
.ext-tasks-stats {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
.tasks-header__right {
|
.ext-tasks-header__right {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
}
|
}
|
||||||
.tasks-view-tabs {
|
.ext-tasks-view-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
}
|
}
|
||||||
.tasks-view-tab {
|
.ext-tasks-view-tab {
|
||||||
padding: 5px 14px;
|
padding: 5px var(--sp-4);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -50,109 +50,109 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.tasks-view-tab:hover { color: var(--text); background: var(--bg-hover); }
|
.ext-tasks-view-tab:hover { color: var(--text); background: var(--bg-hover); }
|
||||||
.tasks-view-tab--active { color: var(--text); background: var(--bg-surface); }
|
.ext-tasks-view-tab--active { color: var(--text); background: var(--bg-surface); }
|
||||||
|
|
||||||
/* ── List View ───────────────────────────── */
|
/* ── List View ───────────────────────────── */
|
||||||
.task-list {
|
.ext-tasks-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
.task-list__filters {
|
.ext-tasks-list__filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
margin-bottom: 8px;
|
margin-bottom: var(--sp-2);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.task-filter {
|
.ext-tasks-filter {
|
||||||
padding: 5px 10px;
|
padding: 5px var(--sp-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.task-list__count {
|
.ext-tasks-list__count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
.task-list__loading, .task-list__empty {
|
.ext-tasks-list__loading, .ext-tasks-list__empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px 0;
|
padding: var(--sp-10) 0;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Task Card ───────────────────────────── */
|
/* ── Task Card ───────────────────────────── */
|
||||||
.task-card {
|
.ext-tasks-card {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 12px 14px;
|
padding: var(--sp-3) var(--sp-4);
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.task-card:hover {
|
.ext-tasks-card:hover {
|
||||||
border-color: var(--border-light);
|
border-color: var(--border-light);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
}
|
}
|
||||||
.task-card__header {
|
.ext-tasks-card__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
.task-card__priority { font-size: 10px; }
|
.ext-tasks-card__priority { font-size: 10px; }
|
||||||
.task-card__title {
|
.ext-tasks-card__title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.task-card__title:hover { color: var(--accent); }
|
.ext-tasks-card__title:hover { color: var(--accent); }
|
||||||
.task-card__status {
|
.ext-tasks-card__status {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 2px 8px;
|
padding: 2px var(--sp-2);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.task-card__status--todo { background: var(--accent-dim); color: var(--accent); }
|
.ext-tasks-card__status--todo { background: var(--accent-dim); color: var(--accent); }
|
||||||
.task-card__status--in_progress { background: var(--warning-dim); color: var(--warning); }
|
.ext-tasks-card__status--in_progress { background: var(--warning-dim); color: var(--warning); }
|
||||||
.task-card__status--done { background: var(--success-dim); color: var(--success); }
|
.ext-tasks-card__status--done { background: var(--success-dim); color: var(--success); }
|
||||||
.task-card__status--cancelled { background: var(--bg-raised); color: var(--text-3); }
|
.ext-tasks-card__status--cancelled { background: var(--bg-raised); color: var(--text-3); }
|
||||||
|
|
||||||
.task-card__desc {
|
.ext-tasks-card__desc {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
margin-top: 6px;
|
margin-top: var(--sp-2);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.task-card__footer {
|
.ext-tasks-card__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
margin-top: 8px;
|
margin-top: var(--sp-2);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
.task-card__actions {
|
.ext-tasks-card__actions {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: var(--sp-1);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.task-card:hover .task-card__actions { opacity: 1; }
|
.ext-tasks-card:hover .ext-tasks-card__actions { opacity: 1; }
|
||||||
|
|
||||||
/* ── Inline buttons ──────────────────────── */
|
/* ── Inline buttons ──────────────────────── */
|
||||||
.task-btn {
|
.ext-tasks-btn {
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
@@ -160,86 +160,86 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.task-btn--sm { padding: 2px 8px; font-size: 13px; }
|
.ext-tasks-btn--sm { padding: 2px var(--sp-2); font-size: 13px; }
|
||||||
.task-btn:hover { background: var(--bg-hover); color: var(--text); }
|
.ext-tasks-btn:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
.task-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
|
.ext-tasks-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
|
||||||
|
|
||||||
/* ── Board View ──────────────────────────── */
|
/* ── Board View ──────────────────────────── */
|
||||||
.task-board {
|
.ext-tasks-board {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
.task-board__col {
|
.ext-tasks-board__col {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.task-board__col-header {
|
.ext-tasks-board__col-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.task-board__col-count {
|
.ext-tasks-board__col-count {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
padding: 1px 7px;
|
padding: 1px 7px;
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
.task-board__col-body {
|
.ext-tasks-board__col-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 8px;
|
padding: var(--sp-2);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
.task-board__card {
|
.ext-tasks-board__card {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: var(--transition);
|
transition: var(--transition);
|
||||||
}
|
}
|
||||||
.task-board__card:hover {
|
.ext-tasks-board__card:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
.task-board__card-title {
|
.ext-tasks-board__card-title {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
.task-board__card-due {
|
.ext-tasks-board__card-due {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
margin-top: 4px;
|
margin-top: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Form ────────────────────────────────── */
|
/* ── Form ────────────────────────────────── */
|
||||||
.task-form { display: flex; flex-direction: column; gap: 12px; }
|
.ext-tasks-form { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
.task-form__row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
.ext-tasks-form__row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
|
||||||
.task-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
.ext-tasks-form__actions { display: flex; justify-content: flex-end; gap: var(--sp-2); margin-top: var(--sp-1); }
|
||||||
.task-form textarea,
|
.ext-tasks-form textarea,
|
||||||
.task-form input,
|
.ext-tasks-form input,
|
||||||
.task-form select {
|
.ext-tasks-form select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 7px 10px;
|
padding: 7px var(--sp-3);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -247,34 +247,34 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
}
|
}
|
||||||
.task-form textarea:focus,
|
.ext-tasks-form textarea:focus,
|
||||||
.task-form input:focus,
|
.ext-tasks-form input:focus,
|
||||||
.task-form select:focus {
|
.ext-tasks-form select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Statusbar widget ────────────────────── */
|
/* ── Statusbar widget ────────────────────── */
|
||||||
.task-status-widget {
|
.ext-tasks-status-widget {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
padding: 4px 10px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Slot containers ─────────────────────── */
|
/* ── Slot containers ─────────────────────── */
|
||||||
.sw-slot {
|
.sw-slot {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
.sw-slot--statusbar {
|
.sw-slot--statusbar {
|
||||||
padding: 4px 12px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.sw-slot--toolbar {
|
.sw-slot--toolbar {
|
||||||
padding: 4px 12px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<${Dialog} open onClose=${onClose} title=${task ? 'Edit Task' : 'New Task'}>
|
<${Dialog} open onClose=${onClose} title=${task ? 'Edit Task' : 'New Task'}>
|
||||||
<form onSubmit=${handleSubmit} class="task-form">
|
<form onSubmit=${handleSubmit} class="ext-tasks-form">
|
||||||
${error && html`<${Banner} variant="danger" text=${error} />`}
|
${error && html`<${Banner} variant="danger" text=${error} />`}
|
||||||
<${FormField} label="Title" required>
|
<${FormField} label="Title" required>
|
||||||
<input type="text" value=${title} onInput=${e => setTitle(e.target.value)}
|
<input type="text" value=${title} onInput=${e => setTitle(e.target.value)}
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
<textarea rows="3" value=${desc} onInput=${e => setDesc(e.target.value)}
|
<textarea rows="3" value=${desc} onInput=${e => setDesc(e.target.value)}
|
||||||
placeholder="Details (optional)" />
|
placeholder="Details (optional)" />
|
||||||
<//>
|
<//>
|
||||||
<div class="task-form__row">
|
<div class="ext-tasks-form__row">
|
||||||
<${FormField} label="Status">
|
<${FormField} label="Status">
|
||||||
<select value=${status} onChange=${e => setStatus(e.target.value)}>
|
<select value=${status} onChange=${e => setStatus(e.target.value)}>
|
||||||
${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)}
|
${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)}
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<//>
|
<//>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-form__row">
|
<div class="ext-tasks-form__row">
|
||||||
<${FormField} label="Due Date">
|
<${FormField} label="Due Date">
|
||||||
<input type="date" value=${dueDate} onInput=${e => setDueDate(e.target.value)} />
|
<input type="date" value=${dueDate} onInput=${e => setDueDate(e.target.value)} />
|
||||||
<//>
|
<//>
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
placeholder="comma-separated" />
|
placeholder="comma-separated" />
|
||||||
<//>
|
<//>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-form__actions">
|
<div class="ext-tasks-form__actions">
|
||||||
<${Button} variant="ghost" type="button" onClick=${onClose}>Cancel<//>
|
<${Button} variant="ghost" type="button" onClick=${onClose}>Cancel<//>
|
||||||
<${Button} variant="primary" type="submit" disabled=${saving}>
|
<${Button} variant="primary" type="submit" disabled=${saving}>
|
||||||
${saving ? 'Saving…' : (task ? 'Update' : 'Create')}
|
${saving ? 'Saving…' : (task ? 'Update' : 'Create')}
|
||||||
@@ -155,45 +155,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="task-list">
|
<div class="ext-tasks-list">
|
||||||
<div class="task-list__filters">
|
<div class="ext-tasks-list__filters">
|
||||||
<select value=${filter} onChange=${e => setFilter(e.target.value)} class="task-filter">
|
<select value=${filter} onChange=${e => setFilter(e.target.value)} class="ext-tasks-filter">
|
||||||
<option value="all">All</option>
|
<option value="all">All</option>
|
||||||
${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)}
|
${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)}
|
||||||
</select>
|
</select>
|
||||||
<span class="task-list__count">${filtered.length} task${filtered.length !== 1 ? 's' : ''}</span>
|
<span class="ext-tasks-list__count">${filtered.length} task${filtered.length !== 1 ? 's' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${loading && html`<div class="task-list__loading"><${Spinner} size="md" /></div>`}
|
${loading && html`<div class="ext-tasks-list__loading"><${Spinner} size="md" /></div>`}
|
||||||
|
|
||||||
${!loading && filtered.length === 0 && html`
|
${!loading && filtered.length === 0 && html`
|
||||||
<div class="task-list__empty">No tasks${filter !== 'all' ? ' matching filter' : ''}. Create one to get started.</div>
|
<div class="ext-tasks-list__empty">No tasks${filter !== 'all' ? ' matching filter' : ''}. Create one to get started.</div>
|
||||||
`}
|
`}
|
||||||
|
|
||||||
${!loading && filtered.map(function(t) {
|
${!loading && filtered.map(function(t) {
|
||||||
return html`
|
return html`
|
||||||
<div class="task-card" key=${t.id}>
|
<div class="ext-tasks-card" key=${t.id}>
|
||||||
<div class="task-card__header">
|
<div class="ext-tasks-card__header">
|
||||||
<span class="task-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">
|
<span class="ext-tasks-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">
|
||||||
●
|
●
|
||||||
</span>
|
</span>
|
||||||
<span class="task-card__title" onClick=${() => onEdit(t)}>${t.title}</span>
|
<span class="ext-tasks-card__title" onClick=${() => onEdit(t)}>${t.title}</span>
|
||||||
<span class="task-card__status task-card__status--${t.status}">
|
<span class="ext-tasks-card__status ext-tasks-card__status--${t.status}">
|
||||||
${STATUS_LABELS[t.status] || t.status}
|
${STATUS_LABELS[t.status] || t.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
${t.description && html`
|
${t.description && html`
|
||||||
<div class="task-card__desc">${t.description}</div>
|
<div class="ext-tasks-card__desc">${t.description}</div>
|
||||||
`}
|
`}
|
||||||
<div class="task-card__footer">
|
<div class="ext-tasks-card__footer">
|
||||||
${t.due_date && html`<span class="task-card__due">Due: ${t.due_date}</span>`}
|
${t.due_date && html`<span class="ext-tasks-card__due">Due: ${t.due_date}</span>`}
|
||||||
${t.tags && html`<span class="task-card__tags">${t.tags}</span>`}
|
${t.tags && html`<span class="ext-tasks-card__tags">${t.tags}</span>`}
|
||||||
<div class="task-card__actions">
|
<div class="ext-tasks-card__actions">
|
||||||
${t.status !== 'done' && html`
|
${t.status !== 'done' && html`
|
||||||
<button class="task-btn task-btn--sm" onClick=${() => handleQuickStatus(t.id, 'done')}
|
<button class="ext-tasks-btn ext-tasks-btn--sm" onClick=${() => handleQuickStatus(t.id, 'done')}
|
||||||
title="Mark done">✓</button>
|
title="Mark done">✓</button>
|
||||||
`}
|
`}
|
||||||
<button class="task-btn task-btn--sm task-btn--danger" onClick=${() => handleDelete(t.id)}
|
<button class="ext-tasks-btn ext-tasks-btn--sm ext-tasks-btn--danger" onClick=${() => handleDelete(t.id)}
|
||||||
title="Delete">×</button>
|
title="Delete">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,25 +216,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="task-board">
|
<div class="ext-tasks-board">
|
||||||
${loading && html`<div class="task-list__loading"><${Spinner} size="md" /></div>`}
|
${loading && html`<div class="ext-tasks-list__loading"><${Spinner} size="md" /></div>`}
|
||||||
${!loading && STATUSES.map(function(status) {
|
${!loading && STATUSES.map(function(status) {
|
||||||
var col = items.filter(function(t) { return t.status === status; });
|
var col = items.filter(function(t) { return t.status === status; });
|
||||||
return html`
|
return html`
|
||||||
<div class="task-board__col" key=${status}>
|
<div class="ext-tasks-board__col" key=${status}>
|
||||||
<div class="task-board__col-header">
|
<div class="ext-tasks-board__col-header">
|
||||||
<span class="task-board__col-title">${STATUS_LABELS[status] || status}</span>
|
<span class="ext-tasks-board__col-title">${STATUS_LABELS[status] || status}</span>
|
||||||
<span class="task-board__col-count">${col.length}</span>
|
<span class="ext-tasks-board__col-count">${col.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-board__col-body">
|
<div class="ext-tasks-board__col-body">
|
||||||
${col.map(function(t) {
|
${col.map(function(t) {
|
||||||
return html`
|
return html`
|
||||||
<div class="task-board__card" key=${t.id} onClick=${() => onEdit(t)}>
|
<div class="ext-tasks-board__card" key=${t.id} onClick=${() => onEdit(t)}>
|
||||||
<div class="task-board__card-title">
|
<div class="ext-tasks-board__card-title">
|
||||||
<span class="task-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">●</span>
|
<span class="ext-tasks-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">●</span>
|
||||||
${t.title}
|
${t.title}
|
||||||
</div>
|
</div>
|
||||||
${t.due_date && html`<div class="task-board__card-due">Due: ${t.due_date}</div>`}
|
${t.due_date && html`<div class="ext-tasks-board__card-due">Due: ${t.due_date}</div>`}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})}
|
})}
|
||||||
@@ -307,22 +307,22 @@
|
|||||||
var Topbar = sw.shell?.Topbar;
|
var Topbar = sw.shell?.Topbar;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="tasks-app">
|
<div class="ext-tasks-app">
|
||||||
${Topbar ? html`
|
${Topbar ? html`
|
||||||
<${Topbar} title="Tasks">
|
<${Topbar} title="Tasks">
|
||||||
${stats && html`<span class="tasks-stats">${stats.total || 0} total</span>`}
|
${stats && html`<span class="ext-tasks-stats">${stats.total || 0} total</span>`}
|
||||||
<div class="tasks-view-tabs">
|
<div class="ext-tasks-view-tabs">
|
||||||
${tabs.map(function(tab) {
|
${tabs.map(function(tab) {
|
||||||
return html`<button key=${tab.id}
|
return html`<button key=${tab.id}
|
||||||
class="tasks-view-tab ${view === tab.id ? 'tasks-view-tab--active' : ''}"
|
class="ext-tasks-view-tab ${view === tab.id ? 'ext-tasks-view-tab--active' : ''}"
|
||||||
onClick=${() => setView(tab.id)}>${tab.label}</button>`;
|
onClick=${() => setView(tab.id)}>${tab.label}</button>`;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//>
|
<${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//>
|
||||||
<//>
|
<//>
|
||||||
` : html`
|
` : html`
|
||||||
<div class="tasks-header">
|
<div class="ext-tasks-header">
|
||||||
<h1 class="tasks-title">Tasks</h1>
|
<h1 class="ext-tasks-title">Tasks</h1>
|
||||||
<${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//>
|
<${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//>
|
||||||
</div>
|
</div>
|
||||||
`}
|
`}
|
||||||
@@ -381,7 +381,7 @@
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (count === null) return null;
|
if (count === null) return null;
|
||||||
return html`<span class="task-status-widget" title="Open tasks">${count} open task${count !== 1 ? 's' : ''}</span>`;
|
return html`<span class="ext-tasks-status-widget" title="Open tasks">${count} open task${count !== 1 ? 's' : ''}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
sw.slots.register('statusbar', {
|
sw.slots.register('statusbar', {
|
||||||
|
|||||||
@@ -2,66 +2,66 @@
|
|||||||
* Uses platform CSS variables exclusively for theming.
|
* Uses platform CSS variables exclusively for theming.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.tal-shell {
|
.ext-team-activity-log-shell {
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 24px 16px;
|
padding: var(--sp-6) var(--sp-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: var(--sp-4);
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Header ────────────────────────────────── */
|
/* ── Header ────────────────────────────────── */
|
||||||
|
|
||||||
.tal-header {
|
.ext-team-activity-log-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-header__left {
|
.ext-team-activity-log-header__left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-title {
|
.ext-team-activity-log-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-subtitle {
|
.ext-team-activity-log-subtitle {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Entry Form ────────────────────────────── */
|
/* ── Entry Form ────────────────────────────── */
|
||||||
|
|
||||||
.tal-form {
|
.ext-team-activity-log-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-form__select {
|
.ext-team-activity-log-form__select {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 6px 8px;
|
padding: var(--sp-2) var(--sp-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-form__input {
|
.ext-team-activity-log-form__input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -69,20 +69,20 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 6px 10px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color var(--transition);
|
transition: border-color var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-form__input:focus {
|
.ext-team-activity-log-form__input:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-form__input::placeholder {
|
.ext-team-activity-log-form__input::placeholder {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-form__btn {
|
.ext-team-activity-log-form__btn {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -90,53 +90,53 @@
|
|||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding: 6px 16px;
|
padding: var(--sp-2) var(--sp-4);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition);
|
transition: background var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-form__btn:hover:not(:disabled) {
|
.ext-team-activity-log-form__btn:hover:not(:disabled) {
|
||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-form__btn:disabled {
|
.ext-team-activity-log-form__btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Filters ───────────────────────────────── */
|
/* ── Filters ───────────────────────────────── */
|
||||||
|
|
||||||
.tal-filters {
|
.ext-team-activity-log-filters {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-filter {
|
.ext-team-activity-log-filter {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
font-family: var(--font);
|
font-family: var(--font);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 4px 10px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-filter:hover {
|
.ext-team-activity-log-filter:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-filter.active {
|
.ext-team-activity-log-filter.ext-team-activity-log-active {
|
||||||
background: var(--accent-dim);
|
background: var(--accent-dim);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-count {
|
.ext-team-activity-log-count {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
@@ -144,70 +144,70 @@
|
|||||||
|
|
||||||
/* ── Entry List ────────────────────────────── */
|
/* ── Entry List ────────────────────────────── */
|
||||||
|
|
||||||
.tal-entries {
|
.ext-team-activity-log-entries {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-entry {
|
.ext-team-activity-log-entry {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
transition: border-color var(--transition);
|
transition: border-color var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-entry:hover {
|
.ext-team-activity-log-entry:hover {
|
||||||
border-color: var(--border-light);
|
border-color: var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-entry__header {
|
.ext-team-activity-log-entry__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
margin-bottom: 4px;
|
margin-bottom: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-entry__category {
|
.ext-team-activity-log-entry__category {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-entry__user {
|
.ext-team-activity-log-entry__user {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-entry__time {
|
.ext-team-activity-log-entry__time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-entry__delete {
|
.ext-team-activity-log-entry__delete {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 4px;
|
padding: 0 var(--sp-1);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-entry:hover .tal-entry__delete {
|
.ext-team-activity-log-entry:hover .ext-team-activity-log-entry__delete {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-entry__delete:hover {
|
.ext-team-activity-log-entry__delete:hover {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tal-entry__message {
|
.ext-team-activity-log-entry__message {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -215,10 +215,10 @@
|
|||||||
|
|
||||||
/* ── Empty / Loading ───────────────────────── */
|
/* ── Empty / Loading ───────────────────────── */
|
||||||
|
|
||||||
.tal-empty,
|
.ext-team-activity-log-empty,
|
||||||
.tal-loading {
|
.ext-team-activity-log-loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px 16px;
|
padding: var(--sp-10) var(--sp-4);
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,34 +104,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="tal-shell">
|
<div class="ext-team-activity-log-shell">
|
||||||
<header class="tal-header">
|
<header class="ext-team-activity-log-header">
|
||||||
<div class="tal-header__left">
|
<div class="ext-team-activity-log-header__left">
|
||||||
<h1 class="tal-title">Activity Log</h1>
|
<h1 class="ext-team-activity-log-title">Activity Log</h1>
|
||||||
<span class="tal-subtitle">Team activity tracker</span>
|
<span class="ext-team-activity-log-subtitle">Team activity tracker</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="tal-header__right">
|
<div class="ext-team-activity-log-header__right">
|
||||||
<div ref=${menuRef}></div>
|
<div ref=${menuRef}></div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<${EntryForm} onSubmit=${addEntry} />
|
<${EntryForm} onSubmit=${addEntry} />
|
||||||
|
|
||||||
<div class="tal-filters">
|
<div class="ext-team-activity-log-filters">
|
||||||
<button class="tal-filter ${filter === '' ? 'active' : ''}"
|
<button class="ext-team-activity-log-filter ${filter === '' ? 'ext-team-activity-log-active' : ''}"
|
||||||
onClick=${() => setFilter('')}>All</button>
|
onClick=${() => setFilter('')}>All</button>
|
||||||
${CATEGORIES.map(c => html`
|
${CATEGORIES.map(c => html`
|
||||||
<button key=${c}
|
<button key=${c}
|
||||||
class="tal-filter ${filter === c ? 'active' : ''}"
|
class="ext-team-activity-log-filter ${filter === c ? 'ext-team-activity-log-active' : ''}"
|
||||||
onClick=${() => setFilter(c)}>
|
onClick=${() => setFilter(c)}>
|
||||||
${c}
|
${c}
|
||||||
</button>
|
</button>
|
||||||
`)}
|
`)}
|
||||||
<span class="tal-count">${entries.length} entries</span>
|
<span class="ext-team-activity-log-count">${entries.length} entries</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${loading
|
${loading
|
||||||
? html`<div class="tal-loading">Loading…</div>`
|
? html`<div class="ext-team-activity-log-loading">Loading…</div>`
|
||||||
: html`<${EntryList} entries=${entries} onDelete=${deleteEntry} />`
|
: html`<${EntryList} entries=${entries} onDelete=${deleteEntry} />`
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -153,17 +153,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<form class="tal-form" onSubmit=${submit}>
|
<form class="ext-team-activity-log-form" onSubmit=${submit}>
|
||||||
<select class="tal-form__select" value=${category}
|
<select class="ext-team-activity-log-form__select" value=${category}
|
||||||
onChange=${e => setCategory(e.target.value)}>
|
onChange=${e => setCategory(e.target.value)}>
|
||||||
${CATEGORIES.map(c => html`<option key=${c} value=${c}>${c}</option>`)}
|
${CATEGORIES.map(c => html`<option key=${c} value=${c}>${c}</option>`)}
|
||||||
</select>
|
</select>
|
||||||
<input class="tal-form__input" type="text"
|
<input class="ext-team-activity-log-form__input" type="text"
|
||||||
placeholder="What happened?"
|
placeholder="What happened?"
|
||||||
value=${message}
|
value=${message}
|
||||||
onInput=${e => setMessage(e.target.value)}
|
onInput=${e => setMessage(e.target.value)}
|
||||||
disabled=${submitting} />
|
disabled=${submitting} />
|
||||||
<button class="tal-form__btn" type="submit" disabled=${submitting || !message.trim()}>
|
<button class="ext-team-activity-log-form__btn" type="submit" disabled=${submitting || !message.trim()}>
|
||||||
${submitting ? '…' : 'Log'}
|
${submitting ? '…' : 'Log'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -172,21 +172,21 @@
|
|||||||
|
|
||||||
function EntryList({ entries, onDelete }) {
|
function EntryList({ entries, onDelete }) {
|
||||||
if (!entries.length) {
|
if (!entries.length) {
|
||||||
return html`<div class="tal-empty">No entries yet. Log your first activity above.</div>`;
|
return html`<div class="ext-team-activity-log-empty">No entries yet. Log your first activity above.</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="tal-entries">
|
<div class="ext-team-activity-log-entries">
|
||||||
${entries.map(e => html`
|
${entries.map(e => html`
|
||||||
<div key=${e.id} class="tal-entry">
|
<div key=${e.id} class="ext-team-activity-log-entry">
|
||||||
<div class="tal-entry__header">
|
<div class="ext-team-activity-log-entry__header">
|
||||||
<span class="tal-entry__category badge">${e.category}</span>
|
<span class="ext-team-activity-log-entry__category badge">${e.category}</span>
|
||||||
<span class="tal-entry__user">${esc(e.username || 'unknown')}</span>
|
<span class="ext-team-activity-log-entry__user">${esc(e.username || 'Unknown')}</span>
|
||||||
<span class="tal-entry__time">${timeAgo(e.created_at)}</span>
|
<span class="ext-team-activity-log-entry__time">${timeAgo(e.created_at)}</span>
|
||||||
<button class="tal-entry__delete" onClick=${() => onDelete(e.id)}
|
<button class="ext-team-activity-log-entry__delete" onClick=${() => onDelete(e.id)}
|
||||||
title="Delete">×</button>
|
title="Delete">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tal-entry__message">${esc(e.message)}</div>
|
<div class="ext-team-activity-log-entry__message">${esc(e.message)}</div>
|
||||||
</div>
|
</div>
|
||||||
`)}
|
`)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,22 +4,22 @@
|
|||||||
defined in variables.css for automatic dark/light support.
|
defined in variables.css for automatic dark/light support.
|
||||||
========================================== */
|
========================================== */
|
||||||
|
|
||||||
.surface-workflow-demo {
|
.ext-workflow-demo {
|
||||||
max-width: 1100px;
|
max-width: 1100px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 1.5rem 2rem;
|
padding: 0 var(--sp-6) var(--sp-8);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Topbar */
|
/* Topbar */
|
||||||
.demo-topbar {
|
.ext-workflow-demo-topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1rem 0;
|
padding: var(--sp-4) 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: var(--sp-4);
|
||||||
}
|
}
|
||||||
.demo-topbar h1 {
|
.ext-workflow-demo-topbar h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -27,65 +27,65 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Subtitle */
|
/* Subtitle */
|
||||||
.demo-subtitle {
|
.ext-workflow-demo-subtitle {
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 var(--sp-6);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Grid */
|
/* Grid */
|
||||||
.demo-grid {
|
.ext-workflow-demo-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 1.5rem;
|
gap: var(--sp-6);
|
||||||
}
|
}
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.demo-grid {
|
.ext-workflow-demo-grid {
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card */
|
/* Card */
|
||||||
.demo-card {
|
.ext-workflow-demo-card {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
padding: 1.25rem;
|
padding: var(--sp-5);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: var(--sp-3);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Title row */
|
/* Title row */
|
||||||
.card-title-row {
|
.ext-workflow-demo-card-title-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
.card-title-row h2 {
|
.ext-workflow-demo-card-title-row h2 {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.card-icon {
|
.ext-workflow-demo-card-icon {
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
}
|
}
|
||||||
.card-tier {
|
.ext-workflow-demo-card-tier {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.badge-browser { background: var(--success-dim); color: var(--success-light); }
|
.ext-workflow-demo-badge-browser { background: var(--success-dim); color: var(--success-light); }
|
||||||
.badge-starlark { background: var(--accent-dim); color: var(--accent-light); }
|
.ext-workflow-demo-badge-starlark { background: var(--accent-dim); color: var(--accent-light); }
|
||||||
|
|
||||||
/* Description */
|
/* Description */
|
||||||
.card-desc {
|
.ext-workflow-demo-card-desc {
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -93,38 +93,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Badges */
|
/* Badges */
|
||||||
.card-badges {
|
.ext-workflow-demo-card-badges {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
.demo-card .badge {
|
.ext-workflow-demo-card .ext-workflow-demo-badge {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.demo-card .badge-installed { background: var(--success-dim); color: var(--success-light); }
|
.ext-workflow-demo-card .ext-workflow-demo-badge-installed { background: var(--success-dim); color: var(--success-light); }
|
||||||
.demo-card .badge-not-installed { background: var(--warning-dim); color: var(--warning-light); }
|
.ext-workflow-demo-card .ext-workflow-demo-badge-not-installed { background: var(--warning-dim); color: var(--warning-light); }
|
||||||
.demo-card .badge-needs-publish { background: var(--danger-dim); color: var(--danger-light); }
|
.ext-workflow-demo-card .ext-workflow-demo-badge-needs-publish { background: var(--danger-dim); color: var(--danger-light); }
|
||||||
|
|
||||||
/* Stage flow diagram */
|
/* Stage flow diagram */
|
||||||
.stage-flow {
|
.ext-workflow-demo-stage-flow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.25rem;
|
gap: var(--sp-1);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 0.75rem;
|
padding: var(--sp-3);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-node {
|
.ext-workflow-demo-stage-node {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
padding: 0.4rem 0.6rem;
|
padding: 0.4rem 0.6rem;
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -132,31 +132,31 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.stage-node.stage-public {
|
.ext-workflow-demo-stage-node.ext-workflow-demo-stage-public {
|
||||||
border-color: var(--success);
|
border-color: var(--success);
|
||||||
background: var(--success-dim);
|
background: var(--success-dim);
|
||||||
}
|
}
|
||||||
.stage-node.stage-system {
|
.ext-workflow-demo-stage-node.ext-workflow-demo-stage-system {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: var(--accent-dim);
|
background: var(--accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-label {
|
.ext-workflow-demo-stage-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
.stage-meta {
|
.ext-workflow-demo-stage-meta {
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
font-size: 0.65rem;
|
font-size: 0.65rem;
|
||||||
}
|
}
|
||||||
.stage-note {
|
.ext-workflow-demo-stage-note {
|
||||||
color: var(--warning-light);
|
color: var(--warning-light);
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-arrow {
|
.ext-workflow-demo-stage-arrow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
@@ -166,91 +166,81 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Collapsible sections */
|
/* Collapsible sections */
|
||||||
.card-collapsible {
|
.ext-workflow-demo-card-collapsible {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
.card-collapsible summary {
|
.ext-workflow-demo-card-collapsible summary {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.card-collapsible summary:hover {
|
.ext-workflow-demo-card-collapsible summary:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
.collapsible-content {
|
.ext-workflow-demo-collapsible-content {
|
||||||
padding: 0 0.75rem 0.75rem;
|
padding: 0 var(--sp-3) var(--sp-3);
|
||||||
}
|
}
|
||||||
.collapsible-content pre {
|
.ext-workflow-demo-collapsible-content pre {
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 0.5rem;
|
padding: var(--sp-2);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
margin: 0.25rem 0;
|
margin: var(--sp-1) 0;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actions */
|
/* Actions */
|
||||||
.card-actions {
|
.ext-workflow-demo-card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: var(--sp-2);
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
padding-top: 0.5rem;
|
padding-top: var(--sp-2);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.demo-card .btn {
|
.ext-workflow-demo-card .ext-workflow-demo-btn {
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.4rem 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.demo-card .btn-primary {
|
/* demo-card buttons — uses kernel .sw-btn--primary */
|
||||||
background: var(--accent);
|
|
||||||
color: var(--text-on-color);
|
|
||||||
}
|
|
||||||
.demo-card .btn-primary:hover {
|
|
||||||
background: var(--accent-hover);
|
|
||||||
}
|
|
||||||
.demo-card .btn-primary:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Public link row */
|
/* Public link row */
|
||||||
.public-link-row {
|
.ext-workflow-demo-public-link-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: var(--sp-2);
|
||||||
margin-top: 0.25rem;
|
margin-top: var(--sp-1);
|
||||||
}
|
}
|
||||||
.public-link-row input {
|
.ext-workflow-demo-public-link-row input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
padding: 4px 8px;
|
padding: var(--sp-1) var(--sp-2);
|
||||||
background: var(--input-bg);
|
background: var(--input-bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
}
|
}
|
||||||
.public-link-row .btn-copy {
|
.ext-workflow-demo-public-link-row .ext-workflow-demo-btn-copy {
|
||||||
padding: 4px 10px;
|
padding: var(--sp-1) var(--sp-3);
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text-2);
|
color: var(--text-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.public-link-row .btn-copy:hover {
|
.ext-workflow-demo-public-link-row .ext-workflow-demo-btn-copy:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,25 +103,25 @@
|
|||||||
if (!mount) return;
|
if (!mount) return;
|
||||||
|
|
||||||
const surface = document.createElement('div');
|
const surface = document.createElement('div');
|
||||||
surface.className = 'surface-workflow-demo';
|
surface.className = 'ext-workflow-demo';
|
||||||
mount.appendChild(surface);
|
mount.appendChild(surface);
|
||||||
|
|
||||||
// Topbar with user menu
|
// Topbar with user menu
|
||||||
const topbar = document.createElement('div');
|
const topbar = document.createElement('div');
|
||||||
topbar.className = 'demo-topbar';
|
topbar.className = 'ext-workflow-demo-topbar';
|
||||||
topbar.innerHTML = '<h1>Workflow Demo</h1>';
|
topbar.innerHTML = '<h1>Workflow Demo</h1>';
|
||||||
surface.appendChild(topbar);
|
surface.appendChild(topbar);
|
||||||
sw.userMenu(topbar, { flyout: 'down' });
|
sw.userMenu(topbar, { flyout: 'down' });
|
||||||
|
|
||||||
// Subtitle
|
// Subtitle
|
||||||
const subtitle = document.createElement('p');
|
const subtitle = document.createElement('p');
|
||||||
subtitle.className = 'demo-subtitle';
|
subtitle.className = 'ext-workflow-demo-subtitle';
|
||||||
subtitle.textContent = 'Four example workflows that prove the engine works end-to-end. Install any package via Admin > Packages.';
|
subtitle.textContent = 'Four example workflows that prove the engine works end-to-end. Install any package via Admin > Packages.';
|
||||||
surface.appendChild(subtitle);
|
surface.appendChild(subtitle);
|
||||||
|
|
||||||
// Workflow cards
|
// Workflow cards
|
||||||
const grid = document.createElement('div');
|
const grid = document.createElement('div');
|
||||||
grid.className = 'demo-grid';
|
grid.className = 'ext-workflow-demo-grid';
|
||||||
surface.appendChild(grid);
|
surface.appendChild(grid);
|
||||||
|
|
||||||
// Fetch installed workflows to check status
|
// Fetch installed workflows to check status
|
||||||
@@ -145,28 +145,28 @@
|
|||||||
|
|
||||||
function _buildCard(wf, installedWf) {
|
function _buildCard(wf, installedWf) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'demo-card';
|
card.className = 'ext-workflow-demo-card';
|
||||||
|
|
||||||
// Title row
|
// Title row
|
||||||
const titleRow = document.createElement('div');
|
const titleRow = document.createElement('div');
|
||||||
titleRow.className = 'card-title-row';
|
titleRow.className = 'ext-workflow-demo-card-title-row';
|
||||||
titleRow.innerHTML = '<span class="card-icon">' + wf.icon + '</span>'
|
titleRow.innerHTML = '<span class="ext-workflow-demo-card-icon">' + wf.icon + '</span>'
|
||||||
+ '<h2>' + _esc(wf.title) + '</h2>'
|
+ '<h2>' + _esc(wf.title) + '</h2>'
|
||||||
+ '<span class="card-tier badge-' + wf.tier + '">' + wf.tier + '</span>';
|
+ '<span class="ext-workflow-demo-card-tier ext-workflow-demo-badge-' + wf.tier + '">' + wf.tier + '</span>';
|
||||||
card.appendChild(titleRow);
|
card.appendChild(titleRow);
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
const desc = document.createElement('p');
|
const desc = document.createElement('p');
|
||||||
desc.className = 'card-desc';
|
desc.className = 'ext-workflow-demo-card-desc';
|
||||||
desc.textContent = wf.description;
|
desc.textContent = wf.description;
|
||||||
card.appendChild(desc);
|
card.appendChild(desc);
|
||||||
|
|
||||||
// Badges
|
// Badges
|
||||||
const badges = document.createElement('div');
|
const badges = document.createElement('div');
|
||||||
badges.className = 'card-badges';
|
badges.className = 'ext-workflow-demo-card-badges';
|
||||||
for (const b of wf.badges) {
|
for (const b of wf.badges) {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.className = 'badge';
|
span.className = 'ext-workflow-demo-badge';
|
||||||
span.textContent = b;
|
span.textContent = b;
|
||||||
badges.appendChild(span);
|
badges.appendChild(span);
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
|
|
||||||
// Status + action
|
// Status + action
|
||||||
const actions = document.createElement('div');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'card-actions';
|
actions.className = 'ext-workflow-demo-card-actions';
|
||||||
|
|
||||||
if (installedWf) {
|
if (installedWf) {
|
||||||
const isActive = installedWf.is_active;
|
const isActive = installedWf.is_active;
|
||||||
@@ -199,24 +199,24 @@
|
|||||||
|
|
||||||
// Installed badge
|
// Installed badge
|
||||||
const status = document.createElement('span');
|
const status = document.createElement('span');
|
||||||
status.className = 'badge badge-installed';
|
status.className = 'ext-workflow-demo-badge ext-workflow-demo-badge-installed';
|
||||||
status.textContent = 'Installed';
|
status.textContent = 'Installed';
|
||||||
actions.appendChild(status);
|
actions.appendChild(status);
|
||||||
|
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
const hint = document.createElement('span');
|
const hint = document.createElement('span');
|
||||||
hint.className = 'badge badge-needs-publish';
|
hint.className = 'ext-workflow-demo-badge ext-workflow-demo-badge-needs-publish';
|
||||||
hint.textContent = 'Not Active';
|
hint.textContent = 'Not Active';
|
||||||
actions.appendChild(hint);
|
actions.appendChild(hint);
|
||||||
} else if (!isPublished) {
|
} else if (!isPublished) {
|
||||||
const hint = document.createElement('span');
|
const hint = document.createElement('span');
|
||||||
hint.className = 'badge badge-needs-publish';
|
hint.className = 'ext-workflow-demo-badge ext-workflow-demo-badge-needs-publish';
|
||||||
hint.textContent = 'Not Published';
|
hint.textContent = 'Not Published';
|
||||||
actions.appendChild(hint);
|
actions.appendChild(hint);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tryBtn = document.createElement('button');
|
const tryBtn = document.createElement('button');
|
||||||
tryBtn.className = 'btn btn-primary';
|
tryBtn.className = 'sw-btn sw-btn--primary sw-btn--md';
|
||||||
tryBtn.textContent = 'Try It';
|
tryBtn.textContent = 'Try It';
|
||||||
tryBtn.disabled = !isReady;
|
tryBtn.disabled = !isReady;
|
||||||
if (!isReady) {
|
if (!isReady) {
|
||||||
@@ -235,14 +235,14 @@
|
|||||||
// Public link for public_link workflows
|
// Public link for public_link workflows
|
||||||
if (installedWf.entry_mode === 'public_link' && isReady) {
|
if (installedWf.entry_mode === 'public_link' && isReady) {
|
||||||
const linkRow = document.createElement('div');
|
const linkRow = document.createElement('div');
|
||||||
linkRow.className = 'public-link-row';
|
linkRow.className = 'ext-workflow-demo-public-link-row';
|
||||||
const linkInput = document.createElement('input');
|
const linkInput = document.createElement('input');
|
||||||
linkInput.type = 'text';
|
linkInput.type = 'text';
|
||||||
linkInput.readOnly = true;
|
linkInput.readOnly = true;
|
||||||
linkInput.value = window.location.origin + base + '/api/v1/public/workflows/' + installedWf.id + '/start';
|
linkInput.value = window.location.origin + base + '/api/v1/public/workflows/' + installedWf.id + '/start';
|
||||||
linkRow.appendChild(linkInput);
|
linkRow.appendChild(linkInput);
|
||||||
const copyBtn = document.createElement('button');
|
const copyBtn = document.createElement('button');
|
||||||
copyBtn.className = 'btn-copy';
|
copyBtn.className = 'ext-workflow-demo-btn-copy';
|
||||||
copyBtn.textContent = 'Copy';
|
copyBtn.textContent = 'Copy';
|
||||||
copyBtn.onclick = function () {
|
copyBtn.onclick = function () {
|
||||||
navigator.clipboard.writeText(linkInput.value).then(function () {
|
navigator.clipboard.writeText(linkInput.value).then(function () {
|
||||||
@@ -255,7 +255,7 @@
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const status = document.createElement('span');
|
const status = document.createElement('span');
|
||||||
status.className = 'badge badge-not-installed';
|
status.className = 'ext-workflow-demo-badge ext-workflow-demo-badge-not-installed';
|
||||||
status.textContent = 'Not Installed';
|
status.textContent = 'Not Installed';
|
||||||
actions.appendChild(status);
|
actions.appendChild(status);
|
||||||
}
|
}
|
||||||
@@ -268,7 +268,7 @@
|
|||||||
|
|
||||||
function _buildStageDiagram(wf) {
|
function _buildStageDiagram(wf) {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.className = 'stage-flow';
|
container.className = 'ext-workflow-demo-stage-flow';
|
||||||
|
|
||||||
const stages = wf.stages;
|
const stages = wf.stages;
|
||||||
let hasBranch = false;
|
let hasBranch = false;
|
||||||
@@ -277,21 +277,21 @@
|
|||||||
const s = stages[i];
|
const s = stages[i];
|
||||||
|
|
||||||
const node = document.createElement('div');
|
const node = document.createElement('div');
|
||||||
node.className = 'stage-node stage-' + s.audience;
|
node.className = 'ext-workflow-demo-stage-node ext-workflow-demo-stage-' + s.audience;
|
||||||
|
|
||||||
const label = document.createElement('div');
|
const label = document.createElement('div');
|
||||||
label.className = 'stage-label';
|
label.className = 'ext-workflow-demo-stage-label';
|
||||||
label.textContent = s.name;
|
label.textContent = s.name;
|
||||||
node.appendChild(label);
|
node.appendChild(label);
|
||||||
|
|
||||||
const meta = document.createElement('div');
|
const meta = document.createElement('div');
|
||||||
meta.className = 'stage-meta';
|
meta.className = 'ext-workflow-demo-stage-meta';
|
||||||
meta.textContent = s.mode + (s.audience !== 'team' ? ' (' + s.audience + ')' : '');
|
meta.textContent = s.mode + (s.audience !== 'team' ? ' (' + s.audience + ')' : '');
|
||||||
node.appendChild(meta);
|
node.appendChild(meta);
|
||||||
|
|
||||||
if (s.note) {
|
if (s.note) {
|
||||||
const note = document.createElement('div');
|
const note = document.createElement('div');
|
||||||
note.className = 'stage-note';
|
note.className = 'ext-workflow-demo-stage-note';
|
||||||
note.textContent = s.note;
|
note.textContent = s.note;
|
||||||
node.appendChild(note);
|
node.appendChild(note);
|
||||||
}
|
}
|
||||||
@@ -301,13 +301,13 @@
|
|||||||
// Arrow (except after last stage)
|
// Arrow (except after last stage)
|
||||||
if (i < stages.length - 1 && !stages[i + 1].branch) {
|
if (i < stages.length - 1 && !stages[i + 1].branch) {
|
||||||
const arrow = document.createElement('div');
|
const arrow = document.createElement('div');
|
||||||
arrow.className = 'stage-arrow';
|
arrow.className = 'ext-workflow-demo-stage-arrow';
|
||||||
arrow.textContent = '\u2192';
|
arrow.textContent = '\u2192';
|
||||||
container.appendChild(arrow);
|
container.appendChild(arrow);
|
||||||
} else if (stages[i + 1] && stages[i + 1].branch) {
|
} else if (stages[i + 1] && stages[i + 1].branch) {
|
||||||
if (!hasBranch) {
|
if (!hasBranch) {
|
||||||
const split = document.createElement('div');
|
const split = document.createElement('div');
|
||||||
split.className = 'stage-arrow stage-branch';
|
split.className = 'ext-workflow-demo-stage-arrow ext-workflow-demo-stage-branch';
|
||||||
split.textContent = '\u2193\u2197';
|
split.textContent = '\u2193\u2197';
|
||||||
container.appendChild(split);
|
container.appendChild(split);
|
||||||
hasBranch = true;
|
hasBranch = true;
|
||||||
@@ -322,14 +322,14 @@
|
|||||||
|
|
||||||
function _buildCollapsible(title, innerHtml) {
|
function _buildCollapsible(title, innerHtml) {
|
||||||
const details = document.createElement('details');
|
const details = document.createElement('details');
|
||||||
details.className = 'card-collapsible';
|
details.className = 'ext-workflow-demo-card-collapsible';
|
||||||
|
|
||||||
const summary = document.createElement('summary');
|
const summary = document.createElement('summary');
|
||||||
summary.textContent = title;
|
summary.textContent = title;
|
||||||
details.appendChild(summary);
|
details.appendChild(summary);
|
||||||
|
|
||||||
const content = document.createElement('div');
|
const content = document.createElement('div');
|
||||||
content.className = 'collapsible-content';
|
content.className = 'ext-workflow-demo-collapsible-content';
|
||||||
content.innerHTML = innerHtml;
|
content.innerHTML = innerHtml;
|
||||||
details.appendChild(content);
|
details.appendChild(content);
|
||||||
|
|
||||||
|
|||||||
74
scripts/audit-css-collisions.sh
Normal file
74
scripts/audit-css-collisions.sh
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# audit-css-collisions.sh — Find duplicate class selectors across kernel CSS files
|
||||||
|
# Outputs a JSON collision report to stdout.
|
||||||
|
# Usage: bash scripts/audit-css-collisions.sh [css-dir]
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
CSS_DIR="${1:-src/css}"
|
||||||
|
|
||||||
|
# Extract class selectors from CSS files and track their file + line
|
||||||
|
declare -A SEEN # selector -> "file:line file:line ..."
|
||||||
|
|
||||||
|
for f in "$CSS_DIR"/*.css; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
lineno=0
|
||||||
|
while IFS= read -r line; do
|
||||||
|
lineno=$((lineno + 1))
|
||||||
|
# Extract class selectors (e.g. .foo-bar, .foo__bar--baz)
|
||||||
|
# Skip lines that are comments, inside @keyframes, or @media queries
|
||||||
|
[[ "$line" =~ ^[[:space:]]*/\* ]] && continue
|
||||||
|
[[ "$line" =~ ^[[:space:]]*@keyframes ]] && continue
|
||||||
|
|
||||||
|
# Match class selectors at the start of a rule
|
||||||
|
while [[ "$line" =~ \.([a-zA-Z_][a-zA-Z0-9_-]*) ]]; do
|
||||||
|
cls=".${BASH_REMATCH[1]}"
|
||||||
|
entry="$(basename "$f"):$lineno"
|
||||||
|
if [ -n "${SEEN[$cls]:-}" ]; then
|
||||||
|
SEEN[$cls]="${SEEN[$cls]} $entry"
|
||||||
|
else
|
||||||
|
SEEN[$cls]="$entry"
|
||||||
|
fi
|
||||||
|
# Remove the matched portion to find more classes on the same line
|
||||||
|
line="${line#*${BASH_REMATCH[0]}}"
|
||||||
|
done
|
||||||
|
done < "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Build JSON: only emit selectors defined in more than one file
|
||||||
|
echo "["
|
||||||
|
first=true
|
||||||
|
for cls in "${!SEEN[@]}"; do
|
||||||
|
locations="${SEEN[$cls]}"
|
||||||
|
# Get unique files
|
||||||
|
files=()
|
||||||
|
for loc in $locations; do
|
||||||
|
file="${loc%%:*}"
|
||||||
|
found=false
|
||||||
|
for f in "${files[@]:-}"; do
|
||||||
|
[ "$f" = "$file" ] && found=true && break
|
||||||
|
done
|
||||||
|
$found || files+=("$file")
|
||||||
|
done
|
||||||
|
|
||||||
|
# Only report if defined in 2+ different files
|
||||||
|
[ ${#files[@]} -lt 2 ] && continue
|
||||||
|
|
||||||
|
if $first; then first=false; else echo ","; fi
|
||||||
|
|
||||||
|
# Build locations array
|
||||||
|
locs_json=""
|
||||||
|
for loc in $locations; do
|
||||||
|
[ -n "$locs_json" ] && locs_json="$locs_json, "
|
||||||
|
locs_json="$locs_json\"$loc\""
|
||||||
|
done
|
||||||
|
|
||||||
|
files_json=""
|
||||||
|
for f in "${files[@]}"; do
|
||||||
|
[ -n "$files_json" ] && files_json="$files_json, "
|
||||||
|
files_json="$files_json\"$f\""
|
||||||
|
done
|
||||||
|
|
||||||
|
printf ' {"selector": "%s", "files": [%s], "locations": [%s]}' "$cls" "$files_json" "$locs_json"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "]"
|
||||||
146
scripts/audit-touch-targets.sh
Executable file
146
scripts/audit-touch-targets.sh
Executable file
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# audit-touch-targets.sh — Static analysis for 44px minimum touch targets on mobile.
|
||||||
|
# Scans JS templates for clickable elements, cross-references CSS for mobile sizing.
|
||||||
|
# Usage: bash scripts/audit-touch-targets.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CSS_DIR="${1:-src/css}"
|
||||||
|
JS_DIR="${2:-src/js}"
|
||||||
|
PKG_DIR="${3:-packages}"
|
||||||
|
|
||||||
|
VIOLATIONS=0
|
||||||
|
|
||||||
|
# ── Phase 1: Build a map of class -> mobile sizing from CSS ──
|
||||||
|
# For each class, record whether it has min-width/min-height >= 44px in mobile breakpoint
|
||||||
|
declare -A HAS_MOBILE_SIZE # class -> "1" if it has adequate mobile touch target
|
||||||
|
declare -A CLOSE_BUTTONS # class -> "file:line" for close-button-like elements
|
||||||
|
|
||||||
|
# Known close button classes to audit specifically
|
||||||
|
CLOSE_CLASSES=(
|
||||||
|
"sw-banner__close"
|
||||||
|
"sw-toast__close"
|
||||||
|
"sw-dialog__close"
|
||||||
|
"sw-drawer__close"
|
||||||
|
"sw-tabs__arrow"
|
||||||
|
"modal-close"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Scan CSS for mobile breakpoint rules with min-height/min-width >= 44px
|
||||||
|
for f in "$CSS_DIR"/*.css "$PKG_DIR"/*/css/main.css; do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
in_mobile=0
|
||||||
|
lineno=0
|
||||||
|
current_selector=""
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
lineno=$((lineno + 1))
|
||||||
|
trimmed="${line#"${line%%[![:space:]]*}"}"
|
||||||
|
|
||||||
|
# Enter mobile @media block
|
||||||
|
if [[ "$trimmed" =~ ^@media.*max-width.*768 ]]; then
|
||||||
|
in_mobile=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( in_mobile )); then
|
||||||
|
# Track brace depth to know when we leave @media
|
||||||
|
if [[ "$trimmed" == "}" ]] && [[ -z "$current_selector" ]]; then
|
||||||
|
in_mobile=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Selector line
|
||||||
|
if [[ "$trimmed" == *"."* ]] && [[ "$trimmed" == *"{"* ]]; then
|
||||||
|
sel="${trimmed%%\{*}"
|
||||||
|
if [[ "$sel" =~ \.([a-zA-Z_][a-zA-Z0-9_-]*) ]]; then
|
||||||
|
current_selector="${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
# Check inline properties
|
||||||
|
if [[ "$trimmed" =~ min-height.*44px ]] || [[ "$trimmed" =~ min-height.*2\.75rem ]] || [[ "$trimmed" =~ min-height.*var\(--sp-12\) ]]; then
|
||||||
|
HAS_MOBILE_SIZE["$current_selector"]=1
|
||||||
|
fi
|
||||||
|
if [[ "$trimmed" =~ min-width.*44px ]] || [[ "$trimmed" =~ min-width.*2\.75rem ]]; then
|
||||||
|
HAS_MOBILE_SIZE["$current_selector"]=1
|
||||||
|
fi
|
||||||
|
[[ "$trimmed" == *"}"* ]] && current_selector=""
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Property line inside mobile block
|
||||||
|
if [[ -n "$current_selector" ]]; then
|
||||||
|
if [[ "$trimmed" =~ min-height.*44px ]] || [[ "$trimmed" =~ min-height.*2\.75rem ]] || [[ "$trimmed" =~ min-height.*var\(--sp-12\) ]]; then
|
||||||
|
HAS_MOBILE_SIZE["$current_selector"]=1
|
||||||
|
fi
|
||||||
|
if [[ "$trimmed" =~ min-width.*44px ]] || [[ "$trimmed" =~ min-width.*2\.75rem ]]; then
|
||||||
|
HAS_MOBILE_SIZE["$current_selector"]=1
|
||||||
|
fi
|
||||||
|
[[ "$trimmed" == "}" ]] && current_selector=""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Touch Target Audit Report ║"
|
||||||
|
echo "╠══════════════════════════════════════════════════════════════════════╣"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Phase 2: Check known close-button classes ──
|
||||||
|
echo "## Close Button Classes"
|
||||||
|
echo ""
|
||||||
|
for cls in "${CLOSE_CLASSES[@]}"; do
|
||||||
|
if [[ -n "${HAS_MOBILE_SIZE[$cls]:-}" ]]; then
|
||||||
|
printf " ✓ .%-30s — has mobile touch target\n" "$cls"
|
||||||
|
else
|
||||||
|
printf " ✗ .%-30s — MISSING min-width/min-height: 44px in mobile breakpoint\n" "$cls"
|
||||||
|
VIOLATIONS=$((VIOLATIONS + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Phase 3: Scan JS for small interactive elements ──
|
||||||
|
echo "## Interactive Elements in JS Templates"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Find elements with onClick or <button that have small/no padding and no mobile override
|
||||||
|
for f in $(find "$JS_DIR" "$PKG_DIR" -name '*.js' -type f 2>/dev/null); do
|
||||||
|
[ -f "$f" ] || continue
|
||||||
|
lineno=0
|
||||||
|
while IFS= read -r line; do
|
||||||
|
lineno=$((lineno + 1))
|
||||||
|
|
||||||
|
# Look for onClick with a class
|
||||||
|
if [[ "$line" =~ onClick ]] && [[ "$line" =~ class.*\"([a-zA-Z_][a-zA-Z0-9_\ -]*)\" ]]; then
|
||||||
|
classes="${BASH_REMATCH[1]}"
|
||||||
|
for cls in $classes; do
|
||||||
|
# Skip known-good patterns (buttons with adequate padding)
|
||||||
|
[[ "$cls" == sw-btn* ]] && continue
|
||||||
|
[[ "$cls" == sw-menu__item* ]] && continue
|
||||||
|
[[ "$cls" == sw-dropdown__option* ]] && continue
|
||||||
|
[[ "$cls" == sw-tabs__tab* ]] && continue
|
||||||
|
# Check if this class has a mobile override
|
||||||
|
if [[ -z "${HAS_MOBILE_SIZE[$cls]:-}" ]]; then
|
||||||
|
# Only flag if it looks like a small interactive element
|
||||||
|
if [[ "$cls" == *close* ]] || [[ "$cls" == *btn* ]] || [[ "$cls" == *toggle* ]] || [[ "$cls" == *trigger* ]] || [[ "$cls" == *action* ]] || [[ "$cls" == *clear* ]] || [[ "$cls" == *delete* ]] || [[ "$cls" == *remove* ]]; then
|
||||||
|
printf " ? %-50s — .%s (no mobile min-size found)\n" "$(basename "$f"):$lineno" "$cls"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
done < "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo "Summary: $VIOLATIONS known violations in close-button classes"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if (( VIOLATIONS > 0 )); then
|
||||||
|
echo "RESULT: FAIL — $VIOLATIONS close button(s) need min-width/min-height: 44px in @media (max-width: 768px)"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "RESULT: PASS — all known interactive elements have adequate mobile touch targets"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
225
scripts/check-contrast.sh
Executable file
225
scripts/check-contrast.sh
Executable file
@@ -0,0 +1,225 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# check-contrast.sh — WCAG AA contrast ratio checker for theme tokens.
|
||||||
|
# Parses variables.css for dark/light token pairs, checks text-on-background contrast.
|
||||||
|
# Usage: bash scripts/check-contrast.sh [variables.css]
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
CSS_FILE="${1:-src/css/variables.css}"
|
||||||
|
|
||||||
|
# Verify python3 is available
|
||||||
|
if ! command -v python3 &>/dev/null; then
|
||||||
|
echo "ERROR: python3 required for luminance computation" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Extract tokens from a CSS block ─────────────────────
|
||||||
|
# Usage: extract_tokens <label> <start-pattern>
|
||||||
|
# Populates an associative array via eval
|
||||||
|
extract_block() {
|
||||||
|
local label="$1" start_pat="$2"
|
||||||
|
local in_block=0 depth=0
|
||||||
|
declare -gA "$label"
|
||||||
|
while IFS= read -r line; do
|
||||||
|
if [[ "$line" =~ $start_pat ]]; then
|
||||||
|
in_block=1; depth=0
|
||||||
|
fi
|
||||||
|
if (( in_block )); then
|
||||||
|
# Count braces
|
||||||
|
opens="${line//[^\{]/}"; depth=$((depth + ${#opens}))
|
||||||
|
closes="${line//[^\}]/}"; depth=$((depth - ${#closes}))
|
||||||
|
# Extract token definitions
|
||||||
|
if [[ "$line" =~ --([a-zA-Z0-9_-]+):[[:space:]]*([^;]+)\; ]]; then
|
||||||
|
local key="${BASH_REMATCH[1]}" val="${BASH_REMATCH[2]}"
|
||||||
|
val="${val%"${val##*[![:space:]]}"}" # trim trailing ws
|
||||||
|
eval "${label}[${key}]=\"${val}\""
|
||||||
|
fi
|
||||||
|
(( depth <= 0 && in_block )) && in_block=0
|
||||||
|
fi
|
||||||
|
done < "$CSS_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
extract_block "DARK" "^:root"
|
||||||
|
extract_block "LIGHT" '^\[data-theme="light"\]'
|
||||||
|
|
||||||
|
# ── Semantic pairings to check ──────────────────────────
|
||||||
|
# Format: "foreground_token:background_token:context:threshold"
|
||||||
|
# threshold: 4.5 = AA normal text, 3.0 = AA large text (>=18px bold or >=24px)
|
||||||
|
PAIRINGS=(
|
||||||
|
"text:bg:body text:4.5"
|
||||||
|
"text:bg-surface:surface text:4.5"
|
||||||
|
"text:bg-raised:raised text:4.5"
|
||||||
|
"text:bg-hover:hover text:4.5"
|
||||||
|
"text:bg-active:active text:4.5"
|
||||||
|
"text-2:bg:secondary text on body:4.5"
|
||||||
|
"text-2:bg-surface:secondary text on surface:4.5"
|
||||||
|
"text-2:bg-raised:secondary text on raised:4.5"
|
||||||
|
"text-3:bg:muted text on body:3.0"
|
||||||
|
"text-3:bg-surface:muted text on surface:3.0"
|
||||||
|
"text-3:bg-raised:muted text on raised:3.0"
|
||||||
|
"accent:bg:accent on body:3.0"
|
||||||
|
"accent:bg-surface:accent on surface:3.0"
|
||||||
|
"accent:bg-raised:accent on raised:3.0"
|
||||||
|
"accent-light:accent-dim:badge accent:3.0"
|
||||||
|
"danger-light:danger-dim:badge danger:3.0"
|
||||||
|
"success-light:success-dim:badge success:3.0"
|
||||||
|
"warning-light:warning-dim:badge warning:3.0"
|
||||||
|
"text-on-color:accent:text on accent btn:3.0"
|
||||||
|
"text-on-color:danger:text on danger btn:3.0"
|
||||||
|
"text-on-color:success:text on success btn:3.0"
|
||||||
|
"text-on-warning:warning:text on warning:4.5"
|
||||||
|
"text:input-bg:input text:4.5"
|
||||||
|
"text-3:input-bg:placeholder on input:3.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Python script for contrast computation ──────────────
|
||||||
|
CONTRAST_PY=$(cat <<'PYEOF'
|
||||||
|
import sys, re, json
|
||||||
|
|
||||||
|
def parse_color(val, base_bg=None):
|
||||||
|
"""Parse hex or rgba() color to (r,g,b) tuple, 0-255."""
|
||||||
|
val = val.strip()
|
||||||
|
m = re.match(r'^#([0-9a-fA-F]{6})$', val)
|
||||||
|
if m:
|
||||||
|
h = m.group(1)
|
||||||
|
return (int(h[0:2],16), int(h[2:4],16), int(h[4:6],16))
|
||||||
|
m = re.match(r'^#([0-9a-fA-F]{3})$', val)
|
||||||
|
if m:
|
||||||
|
h = m.group(1)
|
||||||
|
return (int(h[0]*2,16), int(h[1]*2,16), int(h[2]*2,16))
|
||||||
|
m = re.match(r'^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)$', val)
|
||||||
|
if m:
|
||||||
|
r,g,b = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||||
|
a = float(m.group(4)) if m.group(4) else 1.0
|
||||||
|
if a < 1.0 and base_bg:
|
||||||
|
br, bg_, bb = base_bg
|
||||||
|
r = int(r * a + br * (1 - a))
|
||||||
|
g = int(g * a + bg_ * (1 - a))
|
||||||
|
b = int(b * a + bb * (1 - a))
|
||||||
|
return (r, g, b)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def relative_luminance(rgb):
|
||||||
|
"""WCAG 2.1 relative luminance."""
|
||||||
|
def linearize(c):
|
||||||
|
s = c / 255.0
|
||||||
|
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
|
||||||
|
r, g, b = rgb
|
||||||
|
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
|
||||||
|
|
||||||
|
def contrast_ratio(c1, c2):
|
||||||
|
l1, l2 = relative_luminance(c1), relative_luminance(c2)
|
||||||
|
if l1 < l2: l1, l2 = l2, l1
|
||||||
|
return (l1 + 0.05) / (l2 + 0.05)
|
||||||
|
|
||||||
|
# Read input: JSON with dark/light tokens + pairings
|
||||||
|
data = json.loads(sys.stdin.read())
|
||||||
|
results = []
|
||||||
|
for theme_name, tokens in [("dark", data["dark"]), ("light", data["light"])]:
|
||||||
|
base_bg = parse_color(tokens.get("bg", "#000000"))
|
||||||
|
for p in data["pairings"]:
|
||||||
|
fg_key, bg_key, ctx = p[0], p[1], p[2]
|
||||||
|
fg_val = tokens.get(fg_key)
|
||||||
|
bg_val = tokens.get(bg_key)
|
||||||
|
if not fg_val or not bg_val:
|
||||||
|
results.append({"theme": theme_name, "fg": fg_key, "bg": bg_key, "context": ctx,
|
||||||
|
"ratio": "N/A", "result": "SKIP", "note": "token missing"})
|
||||||
|
continue
|
||||||
|
fg_rgb = parse_color(fg_val, base_bg)
|
||||||
|
bg_rgb = parse_color(bg_val, base_bg)
|
||||||
|
if not fg_rgb or not bg_rgb:
|
||||||
|
results.append({"theme": theme_name, "fg": fg_key, "bg": bg_key, "context": ctx,
|
||||||
|
"ratio": "N/A", "result": "SKIP", "note": "unparseable color"})
|
||||||
|
continue
|
||||||
|
ratio = contrast_ratio(fg_rgb, bg_rgb)
|
||||||
|
threshold = p[3] if len(p) > 3 else 4.5
|
||||||
|
passed = ratio >= threshold
|
||||||
|
level = "AA" if threshold >= 4.5 else "AA-lg"
|
||||||
|
results.append({"theme": theme_name, "fg": fg_key, "bg": bg_key, "context": ctx,
|
||||||
|
"fg_val": fg_val, "bg_val": bg_val,
|
||||||
|
"ratio": f"{ratio:.2f}:1", "result": "PASS" if passed else "FAIL",
|
||||||
|
"level": level})
|
||||||
|
|
||||||
|
json.dump(results, sys.stdout)
|
||||||
|
PYEOF
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Build JSON input for Python ─────────────────────────
|
||||||
|
build_tokens_json() {
|
||||||
|
local -n arr=$1
|
||||||
|
echo "{"
|
||||||
|
local first=true
|
||||||
|
for key in "${!arr[@]}"; do
|
||||||
|
$first && first=false || echo ","
|
||||||
|
# Escape any quotes in value
|
||||||
|
local val="${arr[$key]}"
|
||||||
|
printf ' "%s": "%s"' "$key" "$val"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "}"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_pairings_json() {
|
||||||
|
echo "["
|
||||||
|
local first=true
|
||||||
|
for p in "${PAIRINGS[@]}"; do
|
||||||
|
IFS=':' read -r fg bg ctx threshold <<< "$p"
|
||||||
|
threshold="${threshold:-4.5}"
|
||||||
|
$first && first=false || echo ","
|
||||||
|
printf ' ["%s", "%s", "%s", %s]' "$fg" "$bg" "$ctx" "$threshold"
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
echo "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
INPUT_JSON=$(cat <<JSONEOF
|
||||||
|
{
|
||||||
|
"dark": $(build_tokens_json DARK),
|
||||||
|
"light": $(build_tokens_json LIGHT),
|
||||||
|
"pairings": $(build_pairings_json)
|
||||||
|
}
|
||||||
|
JSONEOF
|
||||||
|
)
|
||||||
|
|
||||||
|
RESULTS=$(echo "$INPUT_JSON" | python3 -c "$CONTRAST_PY")
|
||||||
|
|
||||||
|
# ── Format output ───────────────────────────────────────
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ WCAG AA Contrast Ratio Report ║"
|
||||||
|
echo "╠══════════════════════════════════════════════════════════════════════════════════╣"
|
||||||
|
printf "║ %-6s │ %-20s │ %-16s │ %-10s │ %-6s │ %-5s │ %-6s ║\n" "Theme" "Context" "FG Token" "BG Token" "Ratio" "Level" "Result"
|
||||||
|
echo "╠══════════════════════════════════════════════════════════════════════════════════╣"
|
||||||
|
|
||||||
|
pass=0 fail=0 skip=0
|
||||||
|
while IFS= read -r row; do
|
||||||
|
theme=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['theme'])")
|
||||||
|
ctx=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['context'])")
|
||||||
|
fg=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['fg'])")
|
||||||
|
bg=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['bg'])")
|
||||||
|
ratio=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['ratio'])")
|
||||||
|
result=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['result'])")
|
||||||
|
level=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('level','AA'))")
|
||||||
|
|
||||||
|
case "$result" in
|
||||||
|
PASS) pass=$((pass + 1));;
|
||||||
|
FAIL) fail=$((fail + 1));;
|
||||||
|
SKIP) skip=$((skip + 1));;
|
||||||
|
esac
|
||||||
|
printf "║ %-6s │ %-20s │ %-16s │ %-10s │ %-6s │ %-5s │ %-6s ║\n" "$theme" "$ctx" "$fg" "$bg" "$ratio" "$level" "$result"
|
||||||
|
done < <(echo "$RESULTS" | python3 -c "
|
||||||
|
import sys, json
|
||||||
|
for item in json.loads(sys.stdin.read()):
|
||||||
|
print(json.dumps(item))
|
||||||
|
")
|
||||||
|
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo "Summary: $pass PASS, $fail FAIL, $skip SKIP"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if (( fail > 0 )); then
|
||||||
|
echo "RESULT: FAIL — $fail contrast violation(s) found"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "RESULT: PASS — all checked pairings meet WCAG AA (4.5:1)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
160
scripts/generate-coverage-matrix.sh
Executable file
160
scripts/generate-coverage-matrix.sh
Executable file
@@ -0,0 +1,160 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# generate-coverage-matrix.sh — Component coverage matrix.
|
||||||
|
# Shows which kernel primitives are used by which surfaces.
|
||||||
|
# Flags deprecated component usage.
|
||||||
|
# Usage: bash scripts/generate-coverage-matrix.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Kernel primitives (CSS class prefix : JS import name) ──
|
||||||
|
declare -A PRIMITIVES=(
|
||||||
|
[button]="sw-btn|Button"
|
||||||
|
[input]="sw-input|sw-field|FormField"
|
||||||
|
[dropdown]="sw-dropdown|Dropdown"
|
||||||
|
[dialog]="sw-dialog|Dialog|confirm("
|
||||||
|
[toast]="sw-toast|toast("
|
||||||
|
[menu]="sw-menu|Menu"
|
||||||
|
[tabs]="sw-tabs|Tabs"
|
||||||
|
[avatar]="sw-avatar|Avatar"
|
||||||
|
[spinner]="sw-spinner|Spinner"
|
||||||
|
[tooltip]="sw-tooltip|Tooltip"
|
||||||
|
[drawer]="sw-drawer|Drawer"
|
||||||
|
[banner]="sw-banner|Banner"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ordered list for consistent output
|
||||||
|
PRIM_ORDER=(button input dropdown dialog toast menu tabs avatar spinner tooltip drawer banner)
|
||||||
|
|
||||||
|
# ── Deprecated patterns ──
|
||||||
|
DEPRECATED_PATTERNS="btn-primary|btn-small|btn-danger|btn-full|\.toast-container[^-]|popup-menu"
|
||||||
|
|
||||||
|
# ── Surfaces to scan ──
|
||||||
|
declare -A SURFACE_DIRS
|
||||||
|
|
||||||
|
# Kernel surfaces
|
||||||
|
for d in src/js/sw/surfaces/*/; do
|
||||||
|
[ -d "$d" ] || continue
|
||||||
|
name="$(basename "$d")"
|
||||||
|
SURFACE_DIRS["kernel/$name"]="$d"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Shell components
|
||||||
|
SURFACE_DIRS["kernel/shell"]="src/js/sw/shell/"
|
||||||
|
|
||||||
|
# Extension packages
|
||||||
|
for d in packages/*/; do
|
||||||
|
[ -d "$d" ] || continue
|
||||||
|
slug="$(basename "$d")"
|
||||||
|
SURFACE_DIRS["ext/$slug"]="$d"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Sort surface names
|
||||||
|
SURFACES=()
|
||||||
|
while IFS= read -r s; do SURFACES+=("$s"); done < <(printf '%s\n' "${!SURFACE_DIRS[@]}" | sort)
|
||||||
|
|
||||||
|
# ── Scan each surface for each primitive ──
|
||||||
|
declare -A MATRIX # key="primitive:surface" value="1" or "D" (deprecated)
|
||||||
|
|
||||||
|
for surface in "${SURFACES[@]}"; do
|
||||||
|
dir="${SURFACE_DIRS[$surface]}"
|
||||||
|
[ -d "$dir" ] || continue
|
||||||
|
|
||||||
|
# Gather all JS and CSS in this surface
|
||||||
|
content=""
|
||||||
|
while IFS= read -r f; do
|
||||||
|
content+="$(cat "$f")"$'\n'
|
||||||
|
done < <(find "$dir" -type f \( -name '*.js' -o -name '*.css' \) 2>/dev/null)
|
||||||
|
|
||||||
|
for prim in "${PRIM_ORDER[@]}"; do
|
||||||
|
patterns="${PRIMITIVES[$prim]}"
|
||||||
|
IFS='|' read -ra pats <<< "$patterns"
|
||||||
|
for pat in "${pats[@]}"; do
|
||||||
|
if echo "$content" | grep -qF "$pat"; then
|
||||||
|
MATRIX["$prim:$surface"]="1"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check for deprecated patterns
|
||||||
|
if echo "$content" | grep -qE "$DEPRECATED_PATTERNS"; then
|
||||||
|
MATRIX["deprecated:$surface"]="1"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Build markdown table ──
|
||||||
|
echo "# Component Coverage Matrix"
|
||||||
|
echo ""
|
||||||
|
echo "Generated: $(date -u '+%Y-%m-%d %H:%M UTC')"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Header row
|
||||||
|
header="| Primitive |"
|
||||||
|
separator="|-----------|"
|
||||||
|
for surface in "${SURFACES[@]}"; do
|
||||||
|
short="${surface#*/}" # Remove kernel/ or ext/ prefix
|
||||||
|
header+=" $short |"
|
||||||
|
separator+="------|"
|
||||||
|
done
|
||||||
|
echo "$header"
|
||||||
|
echo "$separator"
|
||||||
|
|
||||||
|
# Data rows
|
||||||
|
for prim in "${PRIM_ORDER[@]}"; do
|
||||||
|
row="| **$prim** |"
|
||||||
|
for surface in "${SURFACES[@]}"; do
|
||||||
|
if [[ -n "${MATRIX["$prim:$surface"]:-}" ]]; then
|
||||||
|
row+=" ✓ |"
|
||||||
|
else
|
||||||
|
row+=" · |"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "$row"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Deprecated row
|
||||||
|
row="| ⚠ deprecated |"
|
||||||
|
has_deprecated=false
|
||||||
|
for surface in "${SURFACES[@]}"; do
|
||||||
|
if [[ -n "${MATRIX["deprecated:$surface"]:-}" ]]; then
|
||||||
|
row+=" ⚠ |"
|
||||||
|
has_deprecated=true
|
||||||
|
else
|
||||||
|
row+=" · |"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "$row"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Legend: ✓ = uses kernel primitive, · = not used, ⚠ = deprecated component detected"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Deprecated detail ──
|
||||||
|
if $has_deprecated; then
|
||||||
|
echo "## Deprecated Component Usage"
|
||||||
|
echo ""
|
||||||
|
for surface in "${SURFACES[@]}"; do
|
||||||
|
[[ -z "${MATRIX["deprecated:$surface"]:-}" ]] && continue
|
||||||
|
dir="${SURFACE_DIRS[$surface]}"
|
||||||
|
echo "### $surface"
|
||||||
|
grep -rnE "$DEPRECATED_PATTERNS" "$dir" --include='*.js' --include='*.css' 2>/dev/null | head -10 || true
|
||||||
|
echo ""
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Summary ──
|
||||||
|
total_cells=$(( ${#PRIM_ORDER[@]} * ${#SURFACES[@]} ))
|
||||||
|
used=0
|
||||||
|
for prim in "${PRIM_ORDER[@]}"; do
|
||||||
|
for surface in "${SURFACES[@]}"; do
|
||||||
|
[[ -n "${MATRIX["$prim:$surface"]:-}" ]] && used=$((used + 1))
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "---"
|
||||||
|
echo "Coverage: $used / $total_cells cells ($((used * 100 / total_cells))%)"
|
||||||
|
if $has_deprecated; then
|
||||||
|
echo "STATUS: WARN — deprecated component usage found (see above)"
|
||||||
|
else
|
||||||
|
echo "STATUS: PASS — no deprecated component usage"
|
||||||
|
fi
|
||||||
287
scripts/generate-ui-inventory.sh
Executable file
287
scripts/generate-ui-inventory.sh
Executable file
@@ -0,0 +1,287 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# generate-ui-inventory.sh — Walk kernel + package CSS, extract class selectors with metadata.
|
||||||
|
# Outputs ui-inventory.json to stdout.
|
||||||
|
# Usage: bash scripts/generate-ui-inventory.sh [css-dir] [packages-dir]
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
CSS_DIR="${1:-src/css}"
|
||||||
|
PKG_DIR="${2:-packages}"
|
||||||
|
|
||||||
|
# Collect all CSS files: kernel + packages
|
||||||
|
FILES=()
|
||||||
|
for f in "$CSS_DIR"/*.css; do [ -f "$f" ] && FILES+=("$f"); done
|
||||||
|
for f in "$PKG_DIR"/*/css/main.css; do [ -f "$f" ] && FILES+=("$f"); done
|
||||||
|
|
||||||
|
# Derive surface name from file path
|
||||||
|
surface_for() {
|
||||||
|
local f="$1"
|
||||||
|
if [[ "$f" == "$PKG_DIR"/* ]]; then
|
||||||
|
local slug
|
||||||
|
slug="$(basename "$(dirname "$(dirname "$f")")")"
|
||||||
|
echo "ext/$slug"
|
||||||
|
else
|
||||||
|
local base
|
||||||
|
base="$(basename "$f" .css)"
|
||||||
|
echo "kernel/$base"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "["
|
||||||
|
first_entry=true
|
||||||
|
|
||||||
|
for f in "${FILES[@]}"; do
|
||||||
|
surface="$(surface_for "$f")"
|
||||||
|
relpath="$f"
|
||||||
|
|
||||||
|
in_comment=0
|
||||||
|
in_keyframes=0
|
||||||
|
in_fontface=0
|
||||||
|
brace_depth=0
|
||||||
|
media_breakpoint=""
|
||||||
|
current_selector=""
|
||||||
|
current_line=0
|
||||||
|
current_classes=""
|
||||||
|
current_spacing=""
|
||||||
|
current_fontsize=""
|
||||||
|
current_breakpoints=""
|
||||||
|
lineno=0
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
lineno=$((lineno + 1))
|
||||||
|
|
||||||
|
# Track block comments
|
||||||
|
if (( in_comment )); then
|
||||||
|
[[ "$line" == *"*/"* ]] && in_comment=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "$line" == *"/*"* ]] && [[ "$line" != *"*/"* ]]; then
|
||||||
|
in_comment=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Trim leading whitespace
|
||||||
|
trimmed="${line#"${line%%[![:space:]]*}"}"
|
||||||
|
[[ -z "$trimmed" ]] && continue
|
||||||
|
[[ "$trimmed" == /\** ]] && continue
|
||||||
|
|
||||||
|
# Track @keyframes blocks
|
||||||
|
if [[ "$trimmed" =~ ^@keyframes ]]; then
|
||||||
|
in_keyframes=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if (( in_keyframes )); then
|
||||||
|
[[ "$trimmed" == "}" ]] && in_keyframes=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Skip @font-face
|
||||||
|
if [[ "$trimmed" =~ ^@font-face ]]; then
|
||||||
|
in_fontface=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if (( in_fontface )); then
|
||||||
|
[[ "$trimmed" == *"}"* ]] && in_fontface=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Track @media breakpoints
|
||||||
|
if [[ "$trimmed" =~ ^@media.*max-width ]]; then
|
||||||
|
if [[ "$trimmed" =~ 768 ]]; then
|
||||||
|
media_breakpoint="768px"
|
||||||
|
elif [[ "$trimmed" =~ 1024 ]]; then
|
||||||
|
media_breakpoint="1024px"
|
||||||
|
else
|
||||||
|
media_breakpoint="other"
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Skip other @-rules
|
||||||
|
[[ "$trimmed" =~ ^@.+ ]] && continue
|
||||||
|
|
||||||
|
# Handle closing brace
|
||||||
|
if [[ "$trimmed" == "}" ]]; then
|
||||||
|
if [[ -n "$media_breakpoint" ]] && (( brace_depth == 0 )); then
|
||||||
|
media_breakpoint=""
|
||||||
|
fi
|
||||||
|
# If we had an open selector block, emit it
|
||||||
|
if [[ -n "$current_selector" ]]; then
|
||||||
|
# Build breakpoints array
|
||||||
|
bp_json="[]"
|
||||||
|
if [[ -n "$current_breakpoints" ]]; then
|
||||||
|
bp_json="["
|
||||||
|
bp_first=true
|
||||||
|
for bp in $current_breakpoints; do
|
||||||
|
$bp_first && bp_first=false || bp_json="$bp_json,"
|
||||||
|
bp_json="$bp_json\"$bp\""
|
||||||
|
done
|
||||||
|
bp_json="$bp_json]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build spacing array
|
||||||
|
sp_json="[]"
|
||||||
|
if [[ -n "$current_spacing" ]]; then
|
||||||
|
sp_json="["
|
||||||
|
sp_first=true
|
||||||
|
# Deduplicate
|
||||||
|
seen_sp=""
|
||||||
|
for sp in $current_spacing; do
|
||||||
|
[[ " $seen_sp " == *" $sp "* ]] && continue
|
||||||
|
seen_sp="$seen_sp $sp"
|
||||||
|
$sp_first && sp_first=false || sp_json="$sp_json,"
|
||||||
|
sp_json="$sp_json\"$sp\""
|
||||||
|
done
|
||||||
|
sp_json="$sp_json]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build font-size array
|
||||||
|
fs_json="[]"
|
||||||
|
if [[ -n "$current_fontsize" ]]; then
|
||||||
|
fs_json="["
|
||||||
|
fs_first=true
|
||||||
|
for fs in $current_fontsize; do
|
||||||
|
$fs_first && fs_first=false || fs_json="$fs_json,"
|
||||||
|
fs_json="$fs_json\"$fs\""
|
||||||
|
done
|
||||||
|
fs_json="$fs_json]"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build classes array
|
||||||
|
cls_json="["
|
||||||
|
cls_first=true
|
||||||
|
for cls in $current_classes; do
|
||||||
|
$cls_first && cls_first=false || cls_json="$cls_json,"
|
||||||
|
cls_json="$cls_json\"$cls\""
|
||||||
|
done
|
||||||
|
cls_json="$cls_json]"
|
||||||
|
|
||||||
|
$first_entry && first_entry=false || printf ",\n"
|
||||||
|
printf ' {"surface":"%s","component":"%s","classes":%s,"file":"%s","line":%d,"responsive_breakpoints":%s,"spacing_tokens_used":%s,"font_size_tokens_used":%s}' \
|
||||||
|
"$surface" "$current_selector" "$cls_json" "$relpath" "$current_line" "$bp_json" "$sp_json" "$fs_json"
|
||||||
|
|
||||||
|
current_selector=""
|
||||||
|
current_classes=""
|
||||||
|
current_spacing=""
|
||||||
|
current_fontsize=""
|
||||||
|
current_breakpoints=""
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Selector line — contains a class and an opening brace
|
||||||
|
if [[ "$trimmed" == *"."* ]] && [[ "$trimmed" == *"{"* ]]; then
|
||||||
|
# Extract the selector part (before {)
|
||||||
|
sel_part="${trimmed%%\{*}"
|
||||||
|
# Extract the property part (after {), may have properties on the same line
|
||||||
|
prop_part="${trimmed#*\{}"
|
||||||
|
prop_part="${prop_part%\}}"
|
||||||
|
|
||||||
|
# Get all class names from the selector
|
||||||
|
classes=""
|
||||||
|
tmp="$sel_part"
|
||||||
|
while [[ "$tmp" =~ \.([a-zA-Z_][a-zA-Z0-9_-]*) ]]; do
|
||||||
|
cls="${BASH_REMATCH[1]}"
|
||||||
|
classes="$classes $cls"
|
||||||
|
tmp="${tmp#*${BASH_REMATCH[0]}}"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Use the first class as the component name
|
||||||
|
first_cls=""
|
||||||
|
for c in $classes; do first_cls="$c"; break; done
|
||||||
|
[[ -z "$first_cls" ]] && continue
|
||||||
|
|
||||||
|
current_selector="$first_cls"
|
||||||
|
current_line=$lineno
|
||||||
|
current_classes="$classes"
|
||||||
|
current_spacing=""
|
||||||
|
current_fontsize=""
|
||||||
|
current_breakpoints=""
|
||||||
|
[[ -n "$media_breakpoint" ]] && current_breakpoints="$media_breakpoint"
|
||||||
|
|
||||||
|
# Check inline properties (single-line rules like .foo { padding: ...; })
|
||||||
|
if [[ -n "$prop_part" ]]; then
|
||||||
|
# Extract spacing tokens
|
||||||
|
sp_tmp="$prop_part"
|
||||||
|
while [[ "$sp_tmp" =~ var\(--sp-([0-9h]+)\) ]]; do
|
||||||
|
current_spacing="$current_spacing --sp-${BASH_REMATCH[1]}"
|
||||||
|
sp_tmp="${sp_tmp#*${BASH_REMATCH[0]}}"
|
||||||
|
done
|
||||||
|
# Extract font-size
|
||||||
|
if [[ "$prop_part" =~ font-size:[[:space:]]*([^;]+) ]]; then
|
||||||
|
fs_val="${BASH_REMATCH[1]}"
|
||||||
|
fs_val="${fs_val%"${fs_val##*[![:space:]]}"}"
|
||||||
|
current_fontsize="$current_fontsize $fs_val"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If the rule is self-closing (has } on the same line), emit now
|
||||||
|
if [[ "$trimmed" == *"}"* ]]; then
|
||||||
|
bp_json="[]"
|
||||||
|
if [[ -n "$current_breakpoints" ]]; then
|
||||||
|
bp_json="[\"$current_breakpoints\"]"
|
||||||
|
fi
|
||||||
|
sp_json="[]"
|
||||||
|
if [[ -n "$current_spacing" ]]; then
|
||||||
|
sp_json="["
|
||||||
|
sp_first=true
|
||||||
|
seen_sp=""
|
||||||
|
for sp in $current_spacing; do
|
||||||
|
[[ " $seen_sp " == *" $sp "* ]] && continue
|
||||||
|
seen_sp="$seen_sp $sp"
|
||||||
|
$sp_first && sp_first=false || sp_json="$sp_json,"
|
||||||
|
sp_json="$sp_json\"$sp\""
|
||||||
|
done
|
||||||
|
sp_json="$sp_json]"
|
||||||
|
fi
|
||||||
|
fs_json="[]"
|
||||||
|
if [[ -n "$current_fontsize" ]]; then
|
||||||
|
fs_json="["
|
||||||
|
fs_first=true
|
||||||
|
for fs in $current_fontsize; do
|
||||||
|
$fs_first && fs_first=false || fs_json="$fs_json,"
|
||||||
|
fs_json="$fs_json\"$fs\""
|
||||||
|
done
|
||||||
|
fs_json="$fs_json]"
|
||||||
|
fi
|
||||||
|
cls_json="["
|
||||||
|
cls_first=true
|
||||||
|
for cls in $current_classes; do
|
||||||
|
$cls_first && cls_first=false || cls_json="$cls_json,"
|
||||||
|
cls_json="$cls_json\"$cls\""
|
||||||
|
done
|
||||||
|
cls_json="$cls_json]"
|
||||||
|
|
||||||
|
$first_entry && first_entry=false || printf ",\n"
|
||||||
|
printf ' {"surface":"%s","component":"%s","classes":%s,"file":"%s","line":%d,"responsive_breakpoints":%s,"spacing_tokens_used":%s,"font_size_tokens_used":%s}' \
|
||||||
|
"$surface" "$current_selector" "$cls_json" "$relpath" "$current_line" "$bp_json" "$sp_json" "$fs_json"
|
||||||
|
|
||||||
|
current_selector=""
|
||||||
|
current_classes=""
|
||||||
|
current_spacing=""
|
||||||
|
current_fontsize=""
|
||||||
|
current_breakpoints=""
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Property line inside an open block
|
||||||
|
if [[ -n "$current_selector" ]]; then
|
||||||
|
# Extract spacing tokens
|
||||||
|
sp_tmp="$trimmed"
|
||||||
|
while [[ "$sp_tmp" =~ var\(--sp-([0-9h]+)\) ]]; do
|
||||||
|
current_spacing="$current_spacing --sp-${BASH_REMATCH[1]}"
|
||||||
|
sp_tmp="${sp_tmp#*${BASH_REMATCH[0]}}"
|
||||||
|
done
|
||||||
|
# Extract font-size
|
||||||
|
if [[ "$trimmed" =~ font-size:[[:space:]]*([^;]+) ]]; then
|
||||||
|
fs_val="${BASH_REMATCH[1]}"
|
||||||
|
fs_val="${fs_val%"${fs_val##*[![:space:]]}"}"
|
||||||
|
current_fontsize="$current_fontsize $fs_val"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
done < "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "]"
|
||||||
129
scripts/lint-package-css.sh
Executable file
129
scripts/lint-package-css.sh
Executable file
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# lint-package-css.sh — Enforce extension CSS prefix convention
|
||||||
|
#
|
||||||
|
# Rule: The FIRST class selector in every rule must start with .ext-{slug}.
|
||||||
|
# Subsequent classes in compound/descendant selectors are allowed (they're
|
||||||
|
# scoped under the extension's namespace). Kernel .sw-* classes are always
|
||||||
|
# allowed. CodeMirror .cm-* classes are allowed as descendants.
|
||||||
|
#
|
||||||
|
# Exemptions: :root, @keyframes blocks, @font-face blocks, @media/@supports
|
||||||
|
# wrappers, CSS comments.
|
||||||
|
#
|
||||||
|
# Usage: bash scripts/lint-package-css.sh [packages-dir]
|
||||||
|
# Exit 0 = all clean, Exit 1 = violations found.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
PKG_DIR="${1:-packages}"
|
||||||
|
VIOLATIONS=0
|
||||||
|
|
||||||
|
for css in "$PKG_DIR"/*/css/main.css; do
|
||||||
|
[ -f "$css" ] || continue
|
||||||
|
|
||||||
|
# Derive slug from directory: {pkg_dir}/{slug}/css/main.css
|
||||||
|
slug="$(basename "$(dirname "$(dirname "$css")")")"
|
||||||
|
prefix="ext-${slug}"
|
||||||
|
|
||||||
|
in_keyframes=0
|
||||||
|
in_fontface=0
|
||||||
|
in_comment=0
|
||||||
|
brace_depth=0
|
||||||
|
lineno=0
|
||||||
|
|
||||||
|
while IFS= read -r line; do
|
||||||
|
lineno=$((lineno + 1))
|
||||||
|
|
||||||
|
# Track block comments
|
||||||
|
if (( in_comment )); then
|
||||||
|
[[ "$line" == *"*/"* ]] && in_comment=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "$line" == *"/*"* ]] && [[ "$line" != *"*/"* ]]; then
|
||||||
|
in_comment=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Skip single-line comments
|
||||||
|
trimmed="${line#"${line%%[![:space:]]*}"}"
|
||||||
|
[[ "$trimmed" == "" ]] && continue
|
||||||
|
[[ "$trimmed" == /\** ]] && continue
|
||||||
|
|
||||||
|
# Track @keyframes blocks
|
||||||
|
if [[ "$trimmed" =~ ^@keyframes ]]; then
|
||||||
|
in_keyframes=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if (( in_keyframes )); then
|
||||||
|
[[ "$trimmed" == "}" ]] && in_keyframes=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Skip @font-face
|
||||||
|
if [[ "$trimmed" =~ ^@font-face ]]; then
|
||||||
|
in_fontface=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if (( in_fontface )); then
|
||||||
|
[[ "$trimmed" == *"}"* ]] && in_fontface=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Skip @-rule wrappers
|
||||||
|
[[ "$trimmed" =~ ^@ ]] && continue
|
||||||
|
|
||||||
|
# Skip :root selectors
|
||||||
|
[[ "$trimmed" =~ ^:root ]] && continue
|
||||||
|
|
||||||
|
# Skip closing braces
|
||||||
|
[[ "$trimmed" == "}" ]] && continue
|
||||||
|
|
||||||
|
# Skip property declarations (contain : but not {)
|
||||||
|
if [[ "$trimmed" == *":"* ]] && [[ "$trimmed" != *"{"* ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Selector line — check if it contains a class
|
||||||
|
if [[ "$trimmed" == *"."* ]]; then
|
||||||
|
# Split comma-separated selectors and check each one
|
||||||
|
# We process the whole line as potentially multiple selectors
|
||||||
|
IFS=',' read -ra selectors <<< "$trimmed"
|
||||||
|
for sel in "${selectors[@]}"; do
|
||||||
|
# Trim whitespace
|
||||||
|
sel="${sel#"${sel%%[![:space:]]*}"}"
|
||||||
|
sel="${sel%"${sel##*[![:space:]]}"}"
|
||||||
|
|
||||||
|
# Remove trailing { if present
|
||||||
|
sel="${sel%\{}"
|
||||||
|
sel="${sel%"${sel##*[![:space:]]}"}"
|
||||||
|
|
||||||
|
# Skip if empty
|
||||||
|
[[ -z "$sel" ]] && continue
|
||||||
|
|
||||||
|
# Find the FIRST class selector in this selector
|
||||||
|
if [[ "$sel" =~ \.([a-zA-Z_][a-zA-Z0-9_-]*) ]]; then
|
||||||
|
first_cls="${BASH_REMATCH[1]}"
|
||||||
|
|
||||||
|
# Allow kernel .sw-* as first class
|
||||||
|
[[ "$first_cls" == sw-* ]] && continue
|
||||||
|
|
||||||
|
# Allow [data-ext=...] scoped selectors
|
||||||
|
[[ "$sel" =~ ^\[data-ext ]] && continue
|
||||||
|
|
||||||
|
# Must start with ext-{slug}
|
||||||
|
if [[ "$first_cls" != ${prefix}* ]]; then
|
||||||
|
echo "VIOLATION: $css:$lineno — .${first_cls} (expected .${prefix}-*)"
|
||||||
|
VIOLATIONS=$((VIOLATIONS + 1))
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
done < "$css"
|
||||||
|
done
|
||||||
|
|
||||||
|
if (( VIOLATIONS > 0 )); then
|
||||||
|
echo ""
|
||||||
|
echo "Found $VIOLATIONS violation(s). First class selector in each rule must start with .ext-{slug}-"
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "All package CSS files pass prefix enforcement."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
@@ -26,11 +26,15 @@ func (p *BuiltinProvider) Authenticate(c *gin.Context, stores store.Stores) (*Re
|
|||||||
var req struct {
|
var req struct {
|
||||||
Login string `json:"login" binding:"required"`
|
Login string `json:"login" binding:"required"`
|
||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password" binding:"required"`
|
||||||
|
KeepLogin bool `json:"keep_login"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stash keep_login in context for generateTokens
|
||||||
|
c.Set("keep_login", req.KeepLogin)
|
||||||
|
|
||||||
user, err := stores.Users.GetByLogin(c.Request.Context(), req.Login)
|
user, err := stores.Users.GetByLogin(c.Request.Context(), req.Login)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrInvalidCreds
|
return nil, ErrInvalidCreds
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|||||||
token_hash TEXT NOT NULL UNIQUE,
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
revoked_at TIMESTAMPTZ
|
revoked_at TIMESTAMPTZ,
|
||||||
|
keep_login BOOLEAN DEFAULT FALSE,
|
||||||
|
last_activity_at TIMESTAMPTZ
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
|
|||||||
token_hash TEXT NOT NULL UNIQUE,
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
expires_at TEXT NOT NULL,
|
expires_at TEXT NOT NULL,
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
revoked_at TEXT
|
revoked_at TEXT,
|
||||||
|
keep_login INTEGER DEFAULT 0,
|
||||||
|
last_activity_at TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens, err := h.generateTokens(result.User)
|
tokens, err := h.generateTokens(result.User, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||||
return
|
return
|
||||||
@@ -94,7 +94,13 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
|
|
||||||
h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID)
|
h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID)
|
||||||
|
|
||||||
tokens, err := h.generateTokens(result.User)
|
// Check for keep_login flag from request body.
|
||||||
|
// The provider already consumed the body, so we read from gin context
|
||||||
|
// if the provider stashed it there; otherwise default to false.
|
||||||
|
keepLogin, _ := c.Get("keep_login")
|
||||||
|
keep, _ := keepLogin.(bool)
|
||||||
|
|
||||||
|
tokens, err := h.generateTokens(result.User, keep)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||||
return
|
return
|
||||||
@@ -113,22 +119,32 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tokenHash := hashToken(req.RefreshToken)
|
tokenHash := hashToken(req.RefreshToken)
|
||||||
userID, err := h.stores.Users.GetRefreshToken(c.Request.Context(), tokenHash)
|
info, err := h.stores.Users.GetRefreshTokenInfo(c.Request.Context(), tokenHash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Idle timeout check
|
||||||
|
sc := LoadSessionConfig(c.Request.Context(), h.stores.GlobalConfig)
|
||||||
|
if sc.IdleTimeout > 0 && info.LastActivityAt != nil {
|
||||||
|
if time.Since(*info.LastActivityAt) > sc.IdleTimeout {
|
||||||
|
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired due to inactivity"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Revoke the used token (rotate)
|
// Revoke the used token (rotate)
|
||||||
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
|
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
|
||||||
|
|
||||||
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
|
user, err := h.stores.Users.GetByID(c.Request.Context(), info.UserID)
|
||||||
if err != nil || !user.IsActive {
|
if err != nil || !user.IsActive {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens, err := h.generateTokens(user)
|
tokens, err := h.generateTokens(user, info.KeepLogin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||||
return
|
return
|
||||||
@@ -159,6 +175,19 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Activity updates last_activity_at on the caller's active refresh tokens.
|
||||||
|
// Called by the client SDK on user interaction (debounced, max 1/min).
|
||||||
|
// POST /api/v1/auth/activity (requires auth middleware)
|
||||||
|
func (h *AuthHandler) Activity(c *gin.Context) {
|
||||||
|
userID, exists := c.Get("user_id")
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.stores.Users.UpdateRefreshTokenActivity(c.Request.Context(), userID.(string))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
// ── OIDC Flow ──────────────────────────────
|
// ── OIDC Flow ──────────────────────────────
|
||||||
|
|
||||||
// OIDCLogin initiates the authorization code flow by redirecting to the IdP.
|
// OIDCLogin initiates the authorization code flow by redirecting to the IdP.
|
||||||
@@ -271,7 +300,7 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Issue internal JWT
|
// Issue internal JWT
|
||||||
tokens, err := h.generateTokens(result.User)
|
tokens, err := h.generateTokens(result.User, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
|
||||||
return
|
return
|
||||||
@@ -286,8 +315,14 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
|
|||||||
refreshToken, _ := tokens["refresh_token"].(string)
|
refreshToken, _ := tokens["refresh_token"].(string)
|
||||||
userJSON, _ := json.Marshal(tokens["user"])
|
userJSON, _ := json.Marshal(tokens["user"])
|
||||||
|
|
||||||
// Set page-auth cookie too (for SSR middleware)
|
// Set page-auth cookie too (for SSR middleware).
|
||||||
c.SetCookie("arm_token", accessToken, 900, "/", "", false, false)
|
// MaxAge matches refresh token lifetime so the cookie
|
||||||
|
// survives until JS can proactively refresh the access token.
|
||||||
|
refreshExpiresIn, _ := tokens["refresh_expires_in"].(int)
|
||||||
|
if refreshExpiresIn <= 0 {
|
||||||
|
refreshExpiresIn = 604800
|
||||||
|
}
|
||||||
|
c.SetCookie("arm_token", accessToken, refreshExpiresIn, "/", "", false, false)
|
||||||
|
|
||||||
// Base64-encode the token payload for the fragment
|
// Base64-encode the token payload for the fragment
|
||||||
payload := fmt.Sprintf(`{"access_token":"%s","refresh_token":"%s","user":%s}`,
|
payload := fmt.Sprintf(`{"access_token":"%s","refresh_token":"%s","user":%s}`,
|
||||||
@@ -298,13 +333,15 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, dest)
|
c.Redirect(http.StatusFound, dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
|
func (h *AuthHandler) generateTokens(user *models.User, keepLogin bool) (gin.H, error) {
|
||||||
// Access token (15 min)
|
sc := LoadSessionConfig(context.Background(), h.stores.GlobalConfig)
|
||||||
|
|
||||||
|
// Access token
|
||||||
accessClaims := Claims{
|
accessClaims := Claims{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(sc.AccessTokenTTL)),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
ID: uuid.New().String(),
|
ID: uuid.New().String(),
|
||||||
},
|
},
|
||||||
@@ -315,12 +352,17 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh token (7 days)
|
// Refresh token — keepLogin=false caps at 24h (session-length)
|
||||||
|
refreshTTL := sc.RefreshTokenTTL
|
||||||
|
if !keepLogin && refreshTTL > 24*time.Hour {
|
||||||
|
refreshTTL = 24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
refreshRaw := uuid.New().String()
|
refreshRaw := uuid.New().String()
|
||||||
refreshHash := hashToken(refreshRaw)
|
refreshHash := hashToken(refreshRaw)
|
||||||
expiresAt := time.Now().Add(7 * 24 * time.Hour)
|
expiresAt := time.Now().Add(refreshTTL)
|
||||||
|
|
||||||
if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt); err != nil {
|
if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt, keepLogin); err != nil {
|
||||||
log.Printf("warn: failed to store refresh token: %v", err)
|
log.Printf("warn: failed to store refresh token: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,7 +370,8 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
|
|||||||
"access_token": accessString,
|
"access_token": accessString,
|
||||||
"refresh_token": refreshRaw,
|
"refresh_token": refreshRaw,
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"expires_in": 900,
|
"expires_in": int(sc.AccessTokenTTL.Seconds()),
|
||||||
|
"refresh_expires_in": int(refreshTTL.Seconds()),
|
||||||
"user": gin.H{
|
"user": gin.H{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"username": user.Username,
|
"username": user.Username,
|
||||||
|
|||||||
@@ -747,6 +747,10 @@ func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
|
|||||||
if p.Type != "surface" && p.Type != "full" {
|
if p.Type != "surface" && p.Type != "full" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Welcome is a fallback surface, not a navigable destination
|
||||||
|
if p.ID == "welcome" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
route, _ := p.Manifest["route"].(string)
|
route, _ := p.Manifest["route"].(string)
|
||||||
icon, _ := p.Manifest["icon"].(string)
|
icon, _ := p.Manifest["icon"].(string)
|
||||||
enabled = append(enabled, navSurface{
|
enabled = append(enabled, navSurface{
|
||||||
|
|||||||
@@ -22,18 +22,12 @@ import (
|
|||||||
"armature/triggers"
|
"armature/triggers"
|
||||||
)
|
)
|
||||||
|
|
||||||
// defaultBundledPackages is the curated set of packages installed by default.
|
// defaultBundledPackages is empty — fresh installs start bare.
|
||||||
// Other packages still ship in the Docker image but require BUNDLED_PACKAGES
|
// Use BUNDLED_PACKAGES env var to control what gets installed per environment:
|
||||||
// to be set explicitly (or "*" for all).
|
// dev: BUNDLED_PACKAGES=*
|
||||||
//
|
// test: BUNDLED_PACKAGES=notes,chat,chat-core
|
||||||
// Recommended production override: BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules
|
// prod: BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules
|
||||||
var defaultBundledPackages = map[string]bool{
|
var defaultBundledPackages = map[string]bool{}
|
||||||
"notes": true,
|
|
||||||
"chat": true,
|
|
||||||
"chat-core": true,
|
|
||||||
"mermaid-renderer": true,
|
|
||||||
"schedules": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// InstallBundledPackages scans bundledDir for .pkg archives and installs
|
// InstallBundledPackages scans bundledDir for .pkg archives and installs
|
||||||
// any that don't already exist in the database. Called once at startup
|
// any that don't already exist in the database. Called once at startup
|
||||||
@@ -74,7 +68,8 @@ func InstallBundledPackages(bundledDir, packagesDir, allowlist string, stores st
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check allowlist by filename (strip .pkg extension = package ID)
|
// Check allowlist by filename (strip .pkg extension = package ID)
|
||||||
if len(allowed) > 0 {
|
// nil = install all ("*"), empty map = install nothing (empty default set)
|
||||||
|
if allowed != nil {
|
||||||
pkgName := strings.TrimSuffix(entry.Name(), ".pkg")
|
pkgName := strings.TrimSuffix(entry.Name(), ".pkg")
|
||||||
if !allowed[pkgName] {
|
if !allowed[pkgName] {
|
||||||
filtered++
|
filtered++
|
||||||
|
|||||||
@@ -441,7 +441,8 @@ func TestExportPackage_NotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBundledInstall_DefaultAllowlist verifies the curated default set behavior.
|
// TestBundledInstall_DefaultAllowlist verifies that an empty allowlist
|
||||||
|
// installs nothing (defaultBundledPackages is empty since v0.6.17).
|
||||||
func TestBundledInstall_DefaultAllowlist(t *testing.T) {
|
func TestBundledInstall_DefaultAllowlist(t *testing.T) {
|
||||||
stores := newTestStores(t)
|
stores := newTestStores(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -449,7 +450,7 @@ func TestBundledInstall_DefaultAllowlist(t *testing.T) {
|
|||||||
bundledDir := t.TempDir()
|
bundledDir := t.TempDir()
|
||||||
packagesDir := t.TempDir()
|
packagesDir := t.TempDir()
|
||||||
|
|
||||||
// Create packages: one in default set, one not
|
// Create packages — neither should be installed with empty defaults
|
||||||
buildTestPkg(t, bundledDir, map[string]any{
|
buildTestPkg(t, bundledDir, map[string]any{
|
||||||
"id": "notes", "title": "Notes", "type": "surface", "version": "1.0.0",
|
"id": "notes", "title": "Notes", "type": "surface", "version": "1.0.0",
|
||||||
})
|
})
|
||||||
@@ -457,19 +458,17 @@ func TestBundledInstall_DefaultAllowlist(t *testing.T) {
|
|||||||
"id": "regex-tester", "title": "Regex Tester", "type": "extension", "version": "1.0.0",
|
"id": "regex-tester", "title": "Regex Tester", "type": "extension", "version": "1.0.0",
|
||||||
})
|
})
|
||||||
|
|
||||||
// Empty allowlist → curated defaults
|
// Empty allowlist → empty default set → nothing installed
|
||||||
InstallBundledPackages(bundledDir, packagesDir, "", stores, nil)
|
InstallBundledPackages(bundledDir, packagesDir, "", stores, nil)
|
||||||
|
|
||||||
// "notes" is in the default set → should be installed
|
pkg, _ := stores.Packages.Get(ctx, "notes")
|
||||||
pkg, err := stores.Packages.Get(ctx, "notes")
|
if pkg != nil {
|
||||||
if err != nil || pkg == nil {
|
t.Fatal("notes should NOT be installed (default set is empty)")
|
||||||
t.Fatal("notes should be installed (in default set)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// "regex-tester" is NOT in the default set → should be filtered
|
|
||||||
pkg, _ = stores.Packages.Get(ctx, "regex-tester")
|
pkg, _ = stores.Packages.Get(ctx, "regex-tester")
|
||||||
if pkg != nil {
|
if pkg != nil {
|
||||||
t.Fatal("regex-tester should NOT be installed (not in default set)")
|
t.Fatal("regex-tester should NOT be installed (default set is empty)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -81,3 +81,38 @@ func (h *PresenceHandler) SearchUsers(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"data": results})
|
c.JSON(http.StatusOK, gin.H{"data": results})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveUsers returns identity records for a batch of user IDs.
|
||||||
|
// GET /api/v1/users/resolve?ids=uuid1,uuid2,...
|
||||||
|
func (h *PresenceHandler) ResolveUsers(c *gin.Context) {
|
||||||
|
raw := strings.TrimSpace(c.Query("ids"))
|
||||||
|
if raw == "" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": map[string]interface{}{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := make([]string, 0)
|
||||||
|
for _, id := range strings.Split(raw, ",") {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id != "" {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": map[string]interface{}{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := h.stores.Users.ResolveByIDs(c.Request.Context(), ids)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "resolve failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build map keyed by user ID for O(1) client-side lookups
|
||||||
|
m := make(map[string]interface{}, len(results))
|
||||||
|
for _, u := range results {
|
||||||
|
m[u.ID] = u
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"data": m})
|
||||||
|
}
|
||||||
|
|||||||
179
server/handlers/presence_test.go
Normal file
179
server/handlers/presence_test.go
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"armature/config"
|
||||||
|
"armature/database"
|
||||||
|
"armature/middleware"
|
||||||
|
"armature/store"
|
||||||
|
postgres "armature/store/postgres"
|
||||||
|
sqlite "armature/store/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Presence/Resolve Test Harness ──────────────
|
||||||
|
|
||||||
|
type presenceHarness struct {
|
||||||
|
*testHarness
|
||||||
|
stores store.Stores
|
||||||
|
userToken string
|
||||||
|
userID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupPresenceHarness(t *testing.T) *presenceHarness {
|
||||||
|
t.Helper()
|
||||||
|
database.RequireTestDB(t)
|
||||||
|
database.TruncateAll(t)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
JWTSecret: testJWTSecret,
|
||||||
|
BasePath: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
var stores store.Stores
|
||||||
|
if database.IsSQLite() {
|
||||||
|
stores = sqlite.NewStores(database.TestDB)
|
||||||
|
} else {
|
||||||
|
stores = postgres.NewStores(database.TestDB)
|
||||||
|
}
|
||||||
|
userCache := middleware.NewUserStatusCache()
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
api := r.Group("/api/v1")
|
||||||
|
protected := api.Group("")
|
||||||
|
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||||
|
|
||||||
|
presence := NewPresenceHandler(stores)
|
||||||
|
protected.GET("/users/resolve", presence.ResolveUsers)
|
||||||
|
protected.GET("/users/search", presence.SearchUsers)
|
||||||
|
|
||||||
|
// Seed a caller user
|
||||||
|
userID := database.SeedTestUser(t, "caller", "caller@test.com")
|
||||||
|
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID)
|
||||||
|
userToken := makeToken(userID, "caller@test.com", "user")
|
||||||
|
|
||||||
|
return &presenceHarness{
|
||||||
|
testHarness: &testHarness{router: r, t: t},
|
||||||
|
stores: stores,
|
||||||
|
userToken: userToken,
|
||||||
|
userID: userID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tests ──────────────────────────────────────
|
||||||
|
|
||||||
|
func TestResolveUsers_SingleID(t *testing.T) {
|
||||||
|
h := setupPresenceHarness(t)
|
||||||
|
|
||||||
|
// Seed a user with display_name
|
||||||
|
targetID := database.SeedTestUser(t, "alice", "alice@test.com")
|
||||||
|
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true, display_name = $1 WHERE id = $2"), "Alice Smith", targetID)
|
||||||
|
|
||||||
|
w := h.request("GET", "/api/v1/users/resolve?ids="+targetID, h.userToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Data map[string]store.UserSearchResult `json:"data"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
|
u, ok := resp.Data[targetID]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected user %s in response, got %v", targetID, resp.Data)
|
||||||
|
}
|
||||||
|
if u.Username != "alice" {
|
||||||
|
t.Errorf("expected username 'alice', got %q", u.Username)
|
||||||
|
}
|
||||||
|
if u.DisplayName != "Alice Smith" {
|
||||||
|
t.Errorf("expected display_name 'Alice Smith', got %q", u.DisplayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUsers_MultipleIDs(t *testing.T) {
|
||||||
|
h := setupPresenceHarness(t)
|
||||||
|
|
||||||
|
id1 := database.SeedTestUser(t, "bob", "bob@test.com")
|
||||||
|
id2 := database.SeedTestUser(t, "carol", "carol@test.com")
|
||||||
|
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), id1)
|
||||||
|
database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true, display_name = $1 WHERE id = $2"), "Carol D", id2)
|
||||||
|
|
||||||
|
w := h.request("GET", "/api/v1/users/resolve?ids="+id1+","+id2, h.userToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Data map[string]store.UserSearchResult `json:"data"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
|
if len(resp.Data) != 2 {
|
||||||
|
t.Fatalf("expected 2 users, got %d", len(resp.Data))
|
||||||
|
}
|
||||||
|
if resp.Data[id1].Username != "bob" {
|
||||||
|
t.Errorf("expected bob, got %q", resp.Data[id1].Username)
|
||||||
|
}
|
||||||
|
if resp.Data[id2].DisplayName != "Carol D" {
|
||||||
|
t.Errorf("expected 'Carol D', got %q", resp.Data[id2].DisplayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUsers_MissingID(t *testing.T) {
|
||||||
|
h := setupPresenceHarness(t)
|
||||||
|
|
||||||
|
w := h.request("GET", "/api/v1/users/resolve?ids=00000000-0000-0000-0000-000000000099", h.userToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Data map[string]store.UserSearchResult `json:"data"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
|
if len(resp.Data) != 0 {
|
||||||
|
t.Errorf("expected empty map for missing ID, got %d entries", len(resp.Data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUsers_EmptyParam(t *testing.T) {
|
||||||
|
h := setupPresenceHarness(t)
|
||||||
|
|
||||||
|
w := h.request("GET", "/api/v1/users/resolve?ids=", h.userToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Data map[string]interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
|
if len(resp.Data) != 0 {
|
||||||
|
t.Errorf("expected empty map, got %d entries", len(resp.Data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveUsers_NoParam(t *testing.T) {
|
||||||
|
h := setupPresenceHarness(t)
|
||||||
|
|
||||||
|
w := h.request("GET", "/api/v1/users/resolve", h.userToken, nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp struct {
|
||||||
|
Data map[string]interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
|
||||||
|
if len(resp.Data) != 0 {
|
||||||
|
t.Errorf("expected empty map, got %d entries", len(resp.Data))
|
||||||
|
}
|
||||||
|
}
|
||||||
103
server/handlers/session_config.go
Normal file
103
server/handlers/session_config.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"armature/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionConfig holds parsed, clamped session TTL settings.
|
||||||
|
type SessionConfig struct {
|
||||||
|
AccessTokenTTL time.Duration
|
||||||
|
RefreshTokenTTL time.Duration
|
||||||
|
IdleTimeout time.Duration // 0 = disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults & clamp bounds
|
||||||
|
const (
|
||||||
|
defaultAccessTTL = 15 * time.Minute
|
||||||
|
defaultRefreshTTL = 7 * 24 * time.Hour // 7d
|
||||||
|
defaultIdleTimeout = 0 // disabled
|
||||||
|
|
||||||
|
minAccessTTL = 5 * time.Minute
|
||||||
|
maxAccessTTL = 60 * time.Minute
|
||||||
|
minRefreshTTL = 1 * time.Hour
|
||||||
|
maxRefreshTTL = 90 * 24 * time.Hour // 90d
|
||||||
|
minIdleTTL = 5 * time.Minute
|
||||||
|
maxIdleTTL = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadSessionConfig reads session settings from the global_settings table.
|
||||||
|
// Returns defaults if the key is missing or values are unparseable.
|
||||||
|
func LoadSessionConfig(ctx context.Context, gc store.GlobalConfigStore) SessionConfig {
|
||||||
|
sc := SessionConfig{
|
||||||
|
AccessTokenTTL: defaultAccessTTL,
|
||||||
|
RefreshTokenTTL: defaultRefreshTTL,
|
||||||
|
IdleTimeout: defaultIdleTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := gc.Get(ctx, "session")
|
||||||
|
if err != nil || raw == nil {
|
||||||
|
return sc
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := raw["access_token_ttl"].(string); ok && v != "" {
|
||||||
|
if d, err := parseDurationString(v); err == nil {
|
||||||
|
sc.AccessTokenTTL = clamp(d, minAccessTTL, maxAccessTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := raw["refresh_token_ttl"].(string); ok && v != "" {
|
||||||
|
if d, err := parseDurationString(v); err == nil {
|
||||||
|
sc.RefreshTokenTTL = clamp(d, minRefreshTTL, maxRefreshTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := raw["idle_timeout"].(string); ok && v != "" {
|
||||||
|
if d, err := parseDurationString(v); err == nil {
|
||||||
|
sc.IdleTimeout = clamp(d, minIdleTTL, maxIdleTTL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sc
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDurationString parses strings like "15m", "2h", "7d", "30d".
|
||||||
|
// Supports suffixes: s (seconds), m (minutes), h (hours), d (days).
|
||||||
|
func parseDurationString(s string) (time.Duration, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0, fmt.Errorf("empty duration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Go's standard parser first (handles "15m", "2h30m", etc.)
|
||||||
|
if d, err := time.ParseDuration(s); err == nil {
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle "Xd" suffix (days)
|
||||||
|
if strings.HasSuffix(s, "d") {
|
||||||
|
numStr := strings.TrimSuffix(s, "d")
|
||||||
|
n, err := strconv.ParseFloat(numStr, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid duration: %s", s)
|
||||||
|
}
|
||||||
|
return time.Duration(n * float64(24*time.Hour)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, fmt.Errorf("invalid duration: %s", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp(d, min, max time.Duration) time.Duration {
|
||||||
|
if d < min {
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
if d > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
188
server/handlers/session_config_test.go
Normal file
188
server/handlers/session_config_test.go
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"armature/models"
|
||||||
|
"armature/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockGlobalConfig implements store.GlobalConfigStore for tests.
|
||||||
|
type mockGlobalConfig struct {
|
||||||
|
data map[string]models.JSONMap
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockGlobalConfig) Get(_ context.Context, key string) (models.JSONMap, error) {
|
||||||
|
if v, ok := m.data[key]; ok {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockGlobalConfig) Set(_ context.Context, _ string, _ models.JSONMap, _ string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockGlobalConfig) GetAll(_ context.Context) (map[string]models.JSONMap, error) {
|
||||||
|
return m.data, nil
|
||||||
|
}
|
||||||
|
func (m *mockGlobalConfig) SaveOIDCState(_ context.Context, _, _, _ string) error { return nil }
|
||||||
|
func (m *mockGlobalConfig) ConsumeOIDCState(_ context.Context, _ string) (string, string, error) {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
func (m *mockGlobalConfig) CleanupOIDCState(_ context.Context) error { return nil }
|
||||||
|
func (m *mockGlobalConfig) GetString(_ context.Context, _ string) (string, error) { return "", nil }
|
||||||
|
|
||||||
|
func newMockGC(session models.JSONMap) store.GlobalConfigStore {
|
||||||
|
return &mockGlobalConfig{data: map[string]models.JSONMap{"session": session}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parseDurationString tests ──────────────
|
||||||
|
|
||||||
|
func TestParseDurationString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want time.Duration
|
||||||
|
err bool
|
||||||
|
}{
|
||||||
|
{"15m", 15 * time.Minute, false},
|
||||||
|
{"2h", 2 * time.Hour, false},
|
||||||
|
{"7d", 7 * 24 * time.Hour, false},
|
||||||
|
{"90d", 90 * 24 * time.Hour, false},
|
||||||
|
{"30m", 30 * time.Minute, false},
|
||||||
|
{"1h30m", 90 * time.Minute, false},
|
||||||
|
{"", 0, true},
|
||||||
|
{"abc", 0, true},
|
||||||
|
{"d", 0, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
got, err := parseDurationString(tt.input)
|
||||||
|
if tt.err {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error for %q, got %v", tt.input, got)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for %q: %v", tt.input, err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("parseDurationString(%q) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LoadSessionConfig tests ────────────────
|
||||||
|
|
||||||
|
func TestSessionConfigDefaults(t *testing.T) {
|
||||||
|
gc := &mockGlobalConfig{data: map[string]models.JSONMap{}}
|
||||||
|
sc := LoadSessionConfig(context.Background(), gc)
|
||||||
|
|
||||||
|
if sc.AccessTokenTTL != 15*time.Minute {
|
||||||
|
t.Errorf("AccessTokenTTL = %v, want 15m", sc.AccessTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.RefreshTokenTTL != 7*24*time.Hour {
|
||||||
|
t.Errorf("RefreshTokenTTL = %v, want 7d", sc.RefreshTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.IdleTimeout != 0 {
|
||||||
|
t.Errorf("IdleTimeout = %v, want 0", sc.IdleTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionConfigCustomValues(t *testing.T) {
|
||||||
|
gc := newMockGC(models.JSONMap{
|
||||||
|
"access_token_ttl": "30m",
|
||||||
|
"refresh_token_ttl": "30d",
|
||||||
|
"idle_timeout": "1h",
|
||||||
|
})
|
||||||
|
|
||||||
|
sc := LoadSessionConfig(context.Background(), gc)
|
||||||
|
|
||||||
|
if sc.AccessTokenTTL != 30*time.Minute {
|
||||||
|
t.Errorf("AccessTokenTTL = %v, want 30m", sc.AccessTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.RefreshTokenTTL != 30*24*time.Hour {
|
||||||
|
t.Errorf("RefreshTokenTTL = %v, want 30d", sc.RefreshTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.IdleTimeout != 1*time.Hour {
|
||||||
|
t.Errorf("IdleTimeout = %v, want 1h", sc.IdleTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionConfigClampLow(t *testing.T) {
|
||||||
|
gc := newMockGC(models.JSONMap{
|
||||||
|
"access_token_ttl": "1m", // below 5m min
|
||||||
|
"refresh_token_ttl": "30m", // below 1h min
|
||||||
|
"idle_timeout": "1m", // below 5m min
|
||||||
|
})
|
||||||
|
|
||||||
|
sc := LoadSessionConfig(context.Background(), gc)
|
||||||
|
|
||||||
|
if sc.AccessTokenTTL != 5*time.Minute {
|
||||||
|
t.Errorf("AccessTokenTTL clamped = %v, want 5m", sc.AccessTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.RefreshTokenTTL != 1*time.Hour {
|
||||||
|
t.Errorf("RefreshTokenTTL clamped = %v, want 1h", sc.RefreshTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.IdleTimeout != 5*time.Minute {
|
||||||
|
t.Errorf("IdleTimeout clamped = %v, want 5m", sc.IdleTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionConfigClampHigh(t *testing.T) {
|
||||||
|
gc := newMockGC(models.JSONMap{
|
||||||
|
"access_token_ttl": "2h", // above 60m max
|
||||||
|
"refresh_token_ttl": "365d", // above 90d max
|
||||||
|
"idle_timeout": "48h", // above 24h max
|
||||||
|
})
|
||||||
|
|
||||||
|
sc := LoadSessionConfig(context.Background(), gc)
|
||||||
|
|
||||||
|
if sc.AccessTokenTTL != 60*time.Minute {
|
||||||
|
t.Errorf("AccessTokenTTL clamped = %v, want 60m", sc.AccessTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.RefreshTokenTTL != 90*24*time.Hour {
|
||||||
|
t.Errorf("RefreshTokenTTL clamped = %v, want 90d", sc.RefreshTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.IdleTimeout != 24*time.Hour {
|
||||||
|
t.Errorf("IdleTimeout clamped = %v, want 24h", sc.IdleTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionConfigEmptyIdleTimeout(t *testing.T) {
|
||||||
|
gc := newMockGC(models.JSONMap{
|
||||||
|
"access_token_ttl": "15m",
|
||||||
|
"refresh_token_ttl": "7d",
|
||||||
|
"idle_timeout": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
sc := LoadSessionConfig(context.Background(), gc)
|
||||||
|
|
||||||
|
if sc.IdleTimeout != 0 {
|
||||||
|
t.Errorf("IdleTimeout = %v, want 0 (disabled)", sc.IdleTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionConfigInvalidValues(t *testing.T) {
|
||||||
|
gc := newMockGC(models.JSONMap{
|
||||||
|
"access_token_ttl": "not-a-duration",
|
||||||
|
"refresh_token_ttl": "xyz",
|
||||||
|
"idle_timeout": "---",
|
||||||
|
})
|
||||||
|
|
||||||
|
sc := LoadSessionConfig(context.Background(), gc)
|
||||||
|
|
||||||
|
// Should fall back to defaults
|
||||||
|
if sc.AccessTokenTTL != 15*time.Minute {
|
||||||
|
t.Errorf("AccessTokenTTL = %v, want 15m (default)", sc.AccessTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.RefreshTokenTTL != 7*24*time.Hour {
|
||||||
|
t.Errorf("RefreshTokenTTL = %v, want 7d (default)", sc.RefreshTokenTTL)
|
||||||
|
}
|
||||||
|
if sc.IdleTimeout != 0 {
|
||||||
|
t.Errorf("IdleTimeout = %v, want 0 (default)", sc.IdleTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -444,6 +444,13 @@ func main() {
|
|||||||
wfScanner.Start()
|
wfScanner.Start()
|
||||||
defer wfScanner.Stop()
|
defer wfScanner.Stop()
|
||||||
|
|
||||||
|
// ── Activity ping (authenticated, rate-limited) ──
|
||||||
|
// Client SDK calls this on user interaction (debounced, max 1/min)
|
||||||
|
// to update last_activity_at for idle-timeout tracking.
|
||||||
|
activityGroup := api.Group("/auth")
|
||||||
|
activityGroup.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||||
|
activityGroup.POST("/activity", authH.Activity)
|
||||||
|
|
||||||
// ── Protected routes ────────────────────
|
// ── Protected routes ────────────────────
|
||||||
protected := api.Group("")
|
protected := api.Group("")
|
||||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||||
@@ -467,8 +474,9 @@ func main() {
|
|||||||
protected.POST("/presence/heartbeat", presence.Heartbeat)
|
protected.POST("/presence/heartbeat", presence.Heartbeat)
|
||||||
protected.GET("/presence", presence.Query)
|
protected.GET("/presence", presence.Query)
|
||||||
|
|
||||||
// User search
|
// User search & resolve
|
||||||
protected.GET("/users/search", presence.SearchUsers)
|
protected.GET("/users/search", presence.SearchUsers)
|
||||||
|
protected.GET("/users/resolve", presence.ResolveUsers)
|
||||||
|
|
||||||
// Workflows
|
// Workflows
|
||||||
wfH := handlers.NewWorkflowHandler(stores)
|
wfH := handlers.NewWorkflowHandler(stores)
|
||||||
|
|||||||
@@ -142,18 +142,43 @@ func parseAndValidateJWT(tokenString string, jwtSecret string) (*Claims, bool) {
|
|||||||
return claims, true
|
return claims, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseJWTIgnoringExpiry parses a JWT and validates the signature but
|
||||||
|
// tolerates an expired token. Used by page-auth middleware so the Go
|
||||||
|
// template can render the page shell even when the access token has
|
||||||
|
// lapsed — the Preact SDK will refresh the token client-side.
|
||||||
|
// Returns (claims, signatureValid). A tampered or unsigned token
|
||||||
|
// returns (nil, false).
|
||||||
|
func parseJWTIgnoringExpiry(tokenString string, jwtSecret string) (*Claims, bool) {
|
||||||
|
claims := &Claims{}
|
||||||
|
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, jwt.ErrSignatureInvalid
|
||||||
|
}
|
||||||
|
return []byte(jwtSecret), nil
|
||||||
|
}, jwt.WithoutClaimsValidation())
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return claims, true
|
||||||
|
}
|
||||||
|
|
||||||
// UserIDFromCookie extracts the user ID from the arm_token cookie without
|
// UserIDFromCookie extracts the user ID from the arm_token cookie without
|
||||||
// requiring authentication. Returns "" if no valid token is found.
|
// requiring authentication. Returns "" if no valid token is found.
|
||||||
// Used by unauthenticated routes that want optional user context.
|
// Tolerates expired tokens (signature must be valid) so that user preferences
|
||||||
|
// still apply even when the access token has lapsed.
|
||||||
func UserIDFromCookie(c *gin.Context, jwtSecret string) string {
|
func UserIDFromCookie(c *gin.Context, jwtSecret string) string {
|
||||||
cookie, err := c.Cookie("arm_token")
|
cookie, err := c.Cookie("arm_token")
|
||||||
if err != nil || cookie == "" {
|
if err != nil || cookie == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
claims, ok := parseAndValidateJWT(cookie, jwtSecret)
|
claims, ok := parseAndValidateJWT(cookie, jwtSecret)
|
||||||
|
if !ok {
|
||||||
|
// Accept expired-but-signed token for optional user context
|
||||||
|
claims, ok = parseJWTIgnoringExpiry(cookie, jwtSecret)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return claims.UserID
|
return claims.UserID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
82
server/middleware/auth_test.go
Normal file
82
server/middleware/auth_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testSecret = "test-jwt-secret-key"
|
||||||
|
|
||||||
|
func makeToken(t *testing.T, userID, email string, expiresAt time.Time) string {
|
||||||
|
t.Helper()
|
||||||
|
claims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Email: email,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
|
||||||
|
ID: "test-jti",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
s, err := token.SignedString([]byte(testSecret))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAndValidateJWT_Valid(t *testing.T) {
|
||||||
|
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(15*time.Minute))
|
||||||
|
claims, ok := parseAndValidateJWT(tok, testSecret)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected valid token to parse")
|
||||||
|
}
|
||||||
|
if claims.UserID != "u1" {
|
||||||
|
t.Errorf("UserID = %q, want u1", claims.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAndValidateJWT_Expired(t *testing.T) {
|
||||||
|
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(-5*time.Minute))
|
||||||
|
_, ok := parseAndValidateJWT(tok, testSecret)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected expired token to be rejected by strict parser")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAndValidateJWT_WrongSecret(t *testing.T) {
|
||||||
|
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(15*time.Minute))
|
||||||
|
_, ok := parseAndValidateJWT(tok, "wrong-secret")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected wrong-secret token to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJWTIgnoringExpiry_Expired(t *testing.T) {
|
||||||
|
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(-5*time.Minute))
|
||||||
|
claims, ok := parseJWTIgnoringExpiry(tok, testSecret)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected expired token to be accepted by lenient parser")
|
||||||
|
}
|
||||||
|
if claims.UserID != "u1" {
|
||||||
|
t.Errorf("UserID = %q, want u1", claims.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJWTIgnoringExpiry_WrongSecret(t *testing.T) {
|
||||||
|
tok := makeToken(t, "u1", "a@b.com", time.Now().Add(-5*time.Minute))
|
||||||
|
_, ok := parseJWTIgnoringExpiry(tok, "wrong-secret")
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected tampered token to be rejected even with lenient parser")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJWTIgnoringExpiry_GarbageToken(t *testing.T) {
|
||||||
|
_, ok := parseJWTIgnoringExpiry("not.a.jwt", testSecret)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected garbage token to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,11 +50,18 @@ func AuthOrRedirect(cfg *config.Config, users store.UserStore, cache *UserStatus
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try strict validation first (token not expired).
|
||||||
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
|
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
|
||||||
|
if !ok {
|
||||||
|
// Fallback: accept an expired-but-signed token so the page
|
||||||
|
// shell can render and the Preact SDK can refresh client-side.
|
||||||
|
// A tampered/unsigned token still gets rejected.
|
||||||
|
claims, ok = parseJWTIgnoringExpiry(tokenString, cfg.JWTSecret)
|
||||||
if !ok {
|
if !ok {
|
||||||
redirectToLogin(c, loginPath)
|
redirectToLogin(c, loginPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if claims.UserID == "" {
|
if claims.UserID == "" {
|
||||||
redirectToLogin(c, loginPath)
|
redirectToLogin(c, loginPath)
|
||||||
|
|||||||
@@ -494,6 +494,10 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
handler := e.RenderSurface(s.ID)
|
handler := e.RenderSurface(s.ID)
|
||||||
|
// Welcome auto-redirects to / when extension surfaces exist
|
||||||
|
if s.ID == "welcome" {
|
||||||
|
handler = e.welcomeRedirectIfSurfaces(handler)
|
||||||
|
}
|
||||||
registerRoutes(group, s, handler)
|
registerRoutes(group, s, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,6 +176,28 @@ func (e *Engine) surfacePath(ctx context.Context, id string) string {
|
|||||||
return "/s/" + id
|
return "/s/" + id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// welcomeRedirectIfSurfaces wraps a welcome surface handler so that
|
||||||
|
// /welcome auto-redirects to / when any extension surface is installed.
|
||||||
|
// This means the welcome page only renders for truly empty deployments.
|
||||||
|
func (e *Engine) welcomeRedirectIfSurfaces(inner gin.HandlerFunc) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if e.stores.Packages != nil {
|
||||||
|
if pkgs, err := e.stores.Packages.List(c.Request.Context()); err == nil {
|
||||||
|
for _, p := range pkgs {
|
||||||
|
if p.Source == "core" || !p.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if p.Type == "surface" || p.Type == "full" {
|
||||||
|
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inner(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// disabledRedirect returns a handler that redirects to /.
|
// disabledRedirect returns a handler that redirects to /.
|
||||||
// The DefaultSurfaceRedirect handler will resolve to the appropriate surface.
|
// The DefaultSurfaceRedirect handler will resolve to the appropriate surface.
|
||||||
func (e *Engine) disabledRedirect() gin.HandlerFunc {
|
func (e *Engine) disabledRedirect() gin.HandlerFunc {
|
||||||
|
|||||||
@@ -27,12 +27,9 @@
|
|||||||
<link rel="stylesheet" href="{{.BasePath}}/surfaces/{{.Surface}}/css/main.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/surfaces/{{.Surface}}/css/main.css?v={{.Version}}">
|
||||||
{{end}}
|
{{end}}
|
||||||
<style>
|
<style>
|
||||||
:root {
|
body { margin: 0; background: var(--bg); color: var(--text); display: flex; flex-direction: column; height: 100vh; height: 100dvh; overflow: hidden; padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0) env(safe-area-inset-bottom, 0) env(safe-area-inset-left, 0); }
|
||||||
--banner-h: 28px;
|
|
||||||
}
|
|
||||||
body { margin: 0; background: var(--bg); color: var(--text); display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
|
||||||
.surface { flex: 1; min-height: 0; overflow: hidden; }
|
.surface { flex: 1; min-height: 0; overflow: hidden; }
|
||||||
.surface-inner { width: 100%; height: 100%; transform-origin: top left; }
|
.surface-inner { width: 100%; height: 100%; overflow: hidden; }
|
||||||
.banner { flex-shrink: 0; }
|
.banner { flex-shrink: 0; }
|
||||||
</style>
|
</style>
|
||||||
<meta name="theme-color" content="#0e0e10" id="metaThemeColor">
|
<meta name="theme-color" content="#0e0e10" id="metaThemeColor">
|
||||||
@@ -52,7 +49,7 @@
|
|||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
})();
|
})();
|
||||||
// Early appearance — apply scale from localStorage on all surfaces.
|
// Early appearance — apply scale from localStorage on all surfaces.
|
||||||
// Scale uses transform on .surface-inner so shell stays fixed, content fills viewport.
|
// CSS zoom reflows layout correctly (no getBoundingClientRect hacks needed).
|
||||||
(function() {
|
(function() {
|
||||||
try {
|
try {
|
||||||
var p = JSON.parse(localStorage.getItem('sb-appearance') || '{}');
|
var p = JSON.parse(localStorage.getItem('sb-appearance') || '{}');
|
||||||
@@ -60,7 +57,7 @@
|
|||||||
var s = p.scale / 100;
|
var s = p.scale / 100;
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
var el = document.getElementById('surfaceInner');
|
var el = document.getElementById('surfaceInner');
|
||||||
if (el) { el.style.transform = 'scale('+s+')'; el.style.transformOrigin = 'top left'; el.style.width = (100/s)+'%'; el.style.height = (100/s)+'%'; }
|
if (el) { el.style.zoom = s; }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
@@ -69,9 +66,12 @@
|
|||||||
</head>
|
</head>
|
||||||
<body data-surface="{{.Surface}}" data-base-path="{{.BasePath}}">
|
<body data-surface="{{.Surface}}" data-base-path="{{.BasePath}}">
|
||||||
{{if .Banner.Visible}}
|
{{if .Banner.Visible}}
|
||||||
<div class="banner banner-top active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};height:var(--banner-h);line-height:var(--banner-h);text-align:center;font-size:12px;font-weight:600;overflow:hidden;">
|
<div class="banner banner-top active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};padding:6px 12px;text-align:center;font-size:12px;font-weight:600;line-height:1.3;">
|
||||||
{{.Banner.Text}}
|
{{.Banner.Text}}
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){ var b=document.querySelector('.banner-top.active'); if(b) document.documentElement.style.setProperty('--banner-top-height',b.offsetHeight+'px'); })();
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .Message.Visible}}
|
{{if .Message.Visible}}
|
||||||
@@ -101,9 +101,12 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .Banner.Visible}}
|
{{if .Banner.Visible}}
|
||||||
<div class="banner banner-bottom active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};height:var(--banner-h);line-height:var(--banner-h);text-align:center;font-size:12px;font-weight:600;overflow:hidden;">
|
<div class="banner banner-bottom active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};padding:6px 12px;text-align:center;font-size:12px;font-weight:600;line-height:1.3;">
|
||||||
{{.Banner.Text}}
|
{{.Banner.Text}}
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){ var b=document.querySelector('.banner-bottom.active'); if(b) document.documentElement.style.setProperty('--banner-bottom-height',b.offsetHeight+'px'); })();
|
||||||
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<script nonce="{{.CSPNonce}}">
|
<script nonce="{{.CSPNonce}}">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
{{if .Multiple}}multiple{{end}}
|
{{if .Multiple}}multiple{{end}}
|
||||||
{{if .Accept}}accept="{{.Accept}}"{{end}}
|
{{if .Accept}}accept="{{.Accept}}"{{end}}
|
||||||
style="display:none">
|
style="display:none">
|
||||||
<button type="button" class="btn-small btn-primary"
|
<button type="button" class="sw-btn sw-btn--primary sw-btn--sm"
|
||||||
onclick="document.getElementById('{{.FieldName}}Input').click()">
|
onclick="document.getElementById('{{.FieldName}}Input').click()">
|
||||||
Choose files…
|
Choose files…
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
<title>Armature</title>
|
<title>Armature</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}">
|
<link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}">
|
||||||
<link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}">
|
<link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/variables.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/variables.css?v={{.Version}}">
|
||||||
<link rel="stylesheet" href="{{.BasePath}}/css/sw-login.css?v={{.Version}}">
|
<link rel="stylesheet" href="{{.BasePath}}/css/sw-login.css?v={{.Version}}">
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -13,6 +13,6 @@
|
|||||||
{{define "surface-extension"}}
|
{{define "surface-extension"}}
|
||||||
<div id="extension-surface" class="extension-surface"
|
<div id="extension-surface" class="extension-surface"
|
||||||
data-surface-id="{{.Surface}}">
|
data-surface-id="{{.Surface}}">
|
||||||
<div id="extension-mount" class="extension-mount"></div>
|
<div id="extension-mount" class="extension-mount" data-ext="{{.Surface}}"></div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body { font-family: var(--font); background: var(--bg); color: var(--text); }
|
body { font-family: var(--font); background: var(--bg); color: var(--text); }
|
||||||
|
|
||||||
.wf-shell { display: flex; flex-direction: column; height: 100vh; }
|
.wf-shell { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
|
||||||
|
|
||||||
.wf-header {
|
.wf-header {
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
|
|||||||
@@ -1355,6 +1355,42 @@ paths:
|
|||||||
items:
|
items:
|
||||||
type: object
|
type: object
|
||||||
|
|
||||||
|
/api/v1/users/resolve:
|
||||||
|
get:
|
||||||
|
summary: Resolve user identities by ID
|
||||||
|
operationId: resolveUsers
|
||||||
|
tags: [Presence]
|
||||||
|
parameters:
|
||||||
|
- name: ids
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
description: Comma-separated user IDs (max 100)
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Map of user ID to identity record
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
display_name:
|
||||||
|
type: string
|
||||||
|
handle:
|
||||||
|
type: string
|
||||||
|
avatar_url:
|
||||||
|
type: string
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# Notifications
|
# Notifications
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ import (
|
|||||||
// ErrSystemGroup is returned when attempting to delete a system-sourced group.
|
// ErrSystemGroup is returned when attempting to delete a system-sourced group.
|
||||||
var ErrSystemGroup = errors.New("system groups cannot be deleted")
|
var ErrSystemGroup = errors.New("system groups cannot be deleted")
|
||||||
|
|
||||||
|
// RefreshTokenInfo holds extended metadata for a refresh token row.
|
||||||
|
type RefreshTokenInfo struct {
|
||||||
|
UserID string
|
||||||
|
KeepLogin bool
|
||||||
|
LastActivityAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
// STORES — Data Access Layer
|
// STORES — Data Access Layer
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -79,11 +86,13 @@ type UserStore interface {
|
|||||||
ListActiveUserIDs(ctx context.Context) ([]string, error)
|
ListActiveUserIDs(ctx context.Context) ([]string, error)
|
||||||
|
|
||||||
// Refresh tokens
|
// Refresh tokens
|
||||||
CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error
|
CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error
|
||||||
GetRefreshToken(ctx context.Context, tokenHash string) (userID string, err error)
|
GetRefreshToken(ctx context.Context, tokenHash string) (userID string, err error)
|
||||||
|
GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*RefreshTokenInfo, error)
|
||||||
RevokeRefreshToken(ctx context.Context, tokenHash string) error
|
RevokeRefreshToken(ctx context.Context, tokenHash string) error
|
||||||
RevokeAllRefreshTokens(ctx context.Context, userID string) error
|
RevokeAllRefreshTokens(ctx context.Context, userID string) error
|
||||||
CleanExpiredTokens(ctx context.Context) error
|
CleanExpiredTokens(ctx context.Context) error
|
||||||
|
UpdateRefreshTokenActivity(ctx context.Context, userID string) error
|
||||||
|
|
||||||
// ── CS1 additions ──
|
// ── CS1 additions ──
|
||||||
|
|
||||||
@@ -94,6 +103,10 @@ type UserStore interface {
|
|||||||
// Excludes the calling user. Max 20 results.
|
// Excludes the calling user. Max 20 results.
|
||||||
SearchActive(ctx context.Context, excludeUserID, query string) ([]UserSearchResult, error)
|
SearchActive(ctx context.Context, excludeUserID, query string) ([]UserSearchResult, error)
|
||||||
|
|
||||||
|
// ResolveByIDs returns lightweight identity records for the given user IDs.
|
||||||
|
// Missing IDs are silently omitted. Max 100 IDs.
|
||||||
|
ResolveByIDs(ctx context.Context, ids []string) ([]UserSearchResult, error)
|
||||||
|
|
||||||
// ── CS2 additions ──
|
// ── CS2 additions ──
|
||||||
|
|
||||||
// CountAll returns the total number of users.
|
// CountAll returns the total number of users.
|
||||||
@@ -123,6 +136,7 @@ type UserSearchResult struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName string `json:"display_name"`
|
||||||
Handle string `json:"handle"`
|
Handle string `json:"handle"`
|
||||||
|
AvatarURL string `json:"avatar_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
@@ -142,8 +142,10 @@ func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
|
|||||||
|
|
||||||
// ── Refresh Tokens ──────────────────────────
|
// ── Refresh Tokens ──────────────────────────
|
||||||
|
|
||||||
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
|
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error {
|
||||||
_, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`, userID, tokenHash, expiresAt)
|
_, err := DB.ExecContext(ctx,
|
||||||
|
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at, keep_login, last_activity_at) VALUES ($1, $2, $3, $4, NOW())`,
|
||||||
|
userID, tokenHash, expiresAt, keepLogin)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
|
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
|
||||||
@@ -151,6 +153,17 @@ func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (stri
|
|||||||
err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`, tokenHash).Scan(&userID)
|
err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`, tokenHash).Scan(&userID)
|
||||||
return userID, err
|
return userID, err
|
||||||
}
|
}
|
||||||
|
func (s *UserStore) GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*store.RefreshTokenInfo, error) {
|
||||||
|
var info store.RefreshTokenInfo
|
||||||
|
err := DB.QueryRowContext(ctx,
|
||||||
|
`SELECT user_id, COALESCE(keep_login, false), last_activity_at
|
||||||
|
FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`,
|
||||||
|
tokenHash).Scan(&info.UserID, &info.KeepLogin, &info.LastActivityAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
|
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
|
||||||
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash)
|
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash)
|
||||||
return err
|
return err
|
||||||
@@ -163,6 +176,12 @@ func (s *UserStore) CleanExpiredTokens(ctx context.Context) error {
|
|||||||
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'")
|
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
func (s *UserStore) UpdateRefreshTokenActivity(ctx context.Context, userID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
"UPDATE refresh_tokens SET last_activity_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL",
|
||||||
|
userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal ────────────────────────────────
|
// ── Internal ────────────────────────────────
|
||||||
|
|
||||||
@@ -231,6 +250,47 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
|
|||||||
return results, rows.Err()
|
return results, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveByIDs returns lightweight identity records for the given user IDs.
|
||||||
|
func (s *UserStore) ResolveByIDs(ctx context.Context, ids []string) ([]store.UserSearchResult, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []store.UserSearchResult{}, nil
|
||||||
|
}
|
||||||
|
if len(ids) > 100 {
|
||||||
|
ids = ids[:100]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build $1,$2,... placeholders
|
||||||
|
placeholders := make([]string, len(ids))
|
||||||
|
args := make([]interface{}, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
q := `SELECT id, username, COALESCE(display_name, '') AS display_name,
|
||||||
|
COALESCE(handle, '') AS handle, COALESCE(avatar_url, '') AS avatar_url
|
||||||
|
FROM users WHERE id IN (` + strings.Join(placeholders, ",") + `)`
|
||||||
|
|
||||||
|
rows, err := DB.QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []store.UserSearchResult
|
||||||
|
for rows.Next() {
|
||||||
|
var u store.UserSearchResult
|
||||||
|
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle, &u.AvatarURL); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, u)
|
||||||
|
}
|
||||||
|
if results == nil {
|
||||||
|
results = []store.UserSearchResult{}
|
||||||
|
}
|
||||||
|
return results, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// ── CS2 additions ─────────────────────────────────────────────
|
// ── CS2 additions ─────────────────────────────────────────────
|
||||||
|
|
||||||
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {
|
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {
|
||||||
|
|||||||
@@ -148,9 +148,10 @@ func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
|
|||||||
|
|
||||||
// ── Refresh Tokens ──────────────────────────
|
// ── Refresh Tokens ──────────────────────────
|
||||||
|
|
||||||
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
|
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error {
|
||||||
_, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)`,
|
_, err := DB.ExecContext(ctx,
|
||||||
store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt))
|
`INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, keep_login, last_activity_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
|
||||||
|
store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt), keepLogin)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
|
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
|
||||||
@@ -158,6 +159,23 @@ func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (stri
|
|||||||
err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`, tokenHash).Scan(&userID)
|
err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`, tokenHash).Scan(&userID)
|
||||||
return userID, err
|
return userID, err
|
||||||
}
|
}
|
||||||
|
func (s *UserStore) GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*store.RefreshTokenInfo, error) {
|
||||||
|
var info store.RefreshTokenInfo
|
||||||
|
var lastAct *string
|
||||||
|
err := DB.QueryRowContext(ctx,
|
||||||
|
`SELECT user_id, COALESCE(keep_login, 0), last_activity_at
|
||||||
|
FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`,
|
||||||
|
tokenHash).Scan(&info.UserID, &info.KeepLogin, &lastAct)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lastAct != nil {
|
||||||
|
if t, err := time.Parse(timeFmt, *lastAct); err == nil {
|
||||||
|
info.LastActivityAt = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
|
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
|
||||||
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash)
|
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash)
|
||||||
return err
|
return err
|
||||||
@@ -170,6 +188,12 @@ func (s *UserStore) CleanExpiredTokens(ctx context.Context) error {
|
|||||||
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-30 days')")
|
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-30 days')")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
func (s *UserStore) UpdateRefreshTokenActivity(ctx context.Context, userID string) error {
|
||||||
|
_, err := DB.ExecContext(ctx,
|
||||||
|
"UPDATE refresh_tokens SET last_activity_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL",
|
||||||
|
userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal ────────────────────────────────
|
// ── Internal ────────────────────────────────
|
||||||
|
|
||||||
@@ -238,6 +262,47 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
|
|||||||
return results, rows.Err()
|
return results, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ResolveByIDs returns lightweight identity records for the given user IDs.
|
||||||
|
func (s *UserStore) ResolveByIDs(ctx context.Context, ids []string) ([]store.UserSearchResult, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []store.UserSearchResult{}, nil
|
||||||
|
}
|
||||||
|
if len(ids) > 100 {
|
||||||
|
ids = ids[:100]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build ?,?,... placeholders
|
||||||
|
placeholders := make([]string, len(ids))
|
||||||
|
args := make([]interface{}, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
placeholders[i] = "?"
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
q := `SELECT id, username, COALESCE(display_name, '') AS display_name,
|
||||||
|
COALESCE(handle, '') AS handle, COALESCE(avatar_url, '') AS avatar_url
|
||||||
|
FROM users WHERE id IN (` + strings.Join(placeholders, ",") + `)`
|
||||||
|
|
||||||
|
rows, err := DB.QueryContext(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []store.UserSearchResult
|
||||||
|
for rows.Next() {
|
||||||
|
var u store.UserSearchResult
|
||||||
|
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle, &u.AvatarURL); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results = append(results, u)
|
||||||
|
}
|
||||||
|
if results == nil {
|
||||||
|
results = []store.UserSearchResult{}
|
||||||
|
}
|
||||||
|
return results, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// ── CS2 additions ─────────────────────────────────────────────
|
// ── CS2 additions ─────────────────────────────────────────────
|
||||||
|
|
||||||
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {
|
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
background: var(--border, #2a2a2e);
|
background: var(--border);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
background: var(--bg-secondary, #151517);
|
background: var(--bg-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-surface-row:hover {
|
.admin-surface-row:hover {
|
||||||
@@ -42,31 +42,31 @@
|
|||||||
.admin-surface-name {
|
.admin-surface-name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-surface-badge {
|
.admin-surface-badge {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-surface-badge--core {
|
.admin-surface-badge--core {
|
||||||
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
|
background: var(--accent-dim);
|
||||||
color: var(--accent, #b38a4e);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-surface-badge--extension {
|
.admin-surface-badge--extension {
|
||||||
background: var(--purple-dim, rgba(160, 120, 255, 0.1));
|
background: var(--purple-dim);
|
||||||
color: var(--purple, #a078ff);
|
color: var(--purple);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-surface-badge--required {
|
.admin-surface-badge--required {
|
||||||
background: var(--bg-raised, #2a2a2e);
|
background: var(--bg-raised);
|
||||||
color: var(--text-3, #555);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-surface-meta {
|
.admin-surface-meta {
|
||||||
@@ -74,18 +74,18 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-3, #555);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-surface-id {
|
.admin-surface-id {
|
||||||
font-family: var(--mono, 'SF Mono', monospace);
|
font-family: var(--mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-surface-route {
|
.admin-surface-route {
|
||||||
font-family: var(--mono, 'SF Mono', monospace);
|
font-family: var(--mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3, #555);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-surface-actions {
|
.admin-surface-actions {
|
||||||
@@ -114,8 +114,8 @@
|
|||||||
height: 20px;
|
height: 20px;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
background: var(--bg-raised, #2a2a2e);
|
background: var(--bg-raised);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-surface-toggle input[type="checkbox"]:checked {
|
.admin-surface-toggle input[type="checkbox"]:checked {
|
||||||
background: var(--accent, #b38a4e);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-surface-toggle input[type="checkbox"]::after {
|
.admin-surface-toggle input[type="checkbox"]::after {
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
|
|
||||||
.admin-surface-toggle-label {
|
.admin-surface-toggle-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-2, #999);
|
color: var(--text-2);
|
||||||
min-width: 56px;
|
min-width: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,36 +159,36 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.dashboard-provider-card {
|
.dashboard-provider-card {
|
||||||
background: var(--bg-surface, #1a1a1d);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
.dashboard-db-pool {
|
.dashboard-db-pool {
|
||||||
background: var(--bg-surface, #1a1a1d);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
.dashboard-pool-bar {
|
.dashboard-pool-bar {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
background: var(--bg-raised, #2a2a2e);
|
background: var(--bg-raised);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.dashboard-pool-fill {
|
.dashboard-pool-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--accent, #b38a4e);
|
background: var(--accent);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
.dashboard-errors {
|
.dashboard-errors {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
background: var(--border, #2a2a2e);
|
background: var(--border);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.dashboard-error-row {
|
.dashboard-error-row {
|
||||||
@@ -196,6 +196,6 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
background: var(--bg-secondary, #151517);
|
background: var(--bg-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,14 +70,14 @@
|
|||||||
|
|
||||||
.sidebar-top {
|
.sidebar-top {
|
||||||
display: flex; flex-direction: column; gap: 2px;
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
padding: 10px 10px 6px;
|
padding: var(--sp-3) var(--sp-3) var(--sp-2);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brand / collapse toggle */
|
/* Brand / collapse toggle */
|
||||||
.sb-brand {
|
.sb-brand {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
padding: 8px 10px; border-radius: var(--radius);
|
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
|
||||||
background: none; border: none; color: var(--text);
|
background: none; border: none; color: var(--text);
|
||||||
cursor: pointer; font-size: 13px; white-space: nowrap;
|
cursor: pointer; font-size: 13px; white-space: nowrap;
|
||||||
transition: background var(--transition); width: 100%;
|
transition: background var(--transition); width: 100%;
|
||||||
@@ -85,13 +85,13 @@
|
|||||||
}
|
}
|
||||||
.sb-brand:hover { background: var(--bg-hover); }
|
.sb-brand:hover { background: var(--bg-hover); }
|
||||||
.sb-logo { flex-shrink: 0; font-size: 18px; line-height: 1; display: flex; align-items: center; justify-content: center; }
|
.sb-logo { flex-shrink: 0; font-size: 18px; line-height: 1; display: flex; align-items: center; justify-content: center; }
|
||||||
.sb-logo img, .sb-logo .brand-logo-img { width: 22px; height: 22px; object-fit: contain; border-radius: 3px; }
|
.sb-logo img, .sb-logo .brand-logo-img { width: 22px; height: 22px; object-fit: contain; border-radius: var(--radius-sm); }
|
||||||
.brand-collapse { display: none; }
|
.brand-collapse { display: none; }
|
||||||
.brand-text { font-weight: 600; font-size: 14px; }
|
.brand-text { font-weight: 600; font-size: 14px; }
|
||||||
|
|
||||||
.sb-btn {
|
.sb-btn {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
padding: 8px 10px; border-radius: var(--radius);
|
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
|
||||||
background: none; border: none; color: var(--text-2);
|
background: none; border: none; color: var(--text-2);
|
||||||
cursor: pointer; font-size: 13px; white-space: nowrap;
|
cursor: pointer; font-size: 13px; white-space: nowrap;
|
||||||
transition: background var(--transition), color var(--transition);
|
transition: background var(--transition), color var(--transition);
|
||||||
@@ -106,19 +106,19 @@
|
|||||||
.sidebar.collapsed .brand-text { opacity: 0; width: 0; }
|
.sidebar.collapsed .brand-text { opacity: 0; width: 0; }
|
||||||
|
|
||||||
/* Collapsed centering */
|
/* Collapsed centering */
|
||||||
.sidebar.collapsed .sidebar-top { padding: 10px 0 8px; align-items: center; }
|
.sidebar.collapsed .sidebar-top { padding: var(--sp-3) 0 var(--sp-2); align-items: center; }
|
||||||
.sidebar.collapsed .sidebar-bottom { padding: 8px 0; display: flex; flex-direction: column; align-items: center; }
|
.sidebar.collapsed .sidebar-bottom { padding: var(--sp-2) 0; display: flex; flex-direction: column; align-items: center; }
|
||||||
.sidebar.collapsed .sb-brand { justify-content: center; padding: 8px 0; gap: 0; }
|
.sidebar.collapsed .sb-brand { justify-content: center; padding: var(--sp-2) 0; gap: 0; }
|
||||||
.sidebar.collapsed .sb-btn { justify-content: center; padding: 8px 0; gap: 0; }
|
.sidebar.collapsed .sb-btn { justify-content: center; padding: var(--sp-2) 0; gap: 0; }
|
||||||
.sidebar.collapsed .split-btn-main { justify-content: center; padding: 8px 0; gap: 0; }
|
.sidebar.collapsed .split-btn-main { justify-content: center; padding: var(--sp-2) 0; gap: 0; }
|
||||||
.sidebar.collapsed .user-btn { justify-content: center; padding: 6px 0; gap: 0; }
|
.sidebar.collapsed .user-btn { justify-content: center; padding: var(--sp-2) 0; gap: 0; }
|
||||||
.sidebar.collapsed .avatar-bug { display: none; }
|
.sidebar.collapsed .avatar-bug { display: none; }
|
||||||
|
|
||||||
/* Split button (New Chat + dropdown) */
|
/* Split button (New Chat + dropdown) */
|
||||||
.split-btn { display: flex; width: 100%; gap: 0; position: relative; }
|
.split-btn { display: flex; width: 100%; gap: 0; position: relative; }
|
||||||
.split-btn-main {
|
.split-btn-main {
|
||||||
flex: 1; display: flex; align-items: center; gap: 10px;
|
flex: 1; display: flex; align-items: center; gap: var(--sp-3);
|
||||||
padding: 8px 10px; border-radius: var(--radius) 0 0 var(--radius);
|
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius) 0 0 var(--radius);
|
||||||
background: none; border: none; color: var(--text-2);
|
background: none; border: none; color: var(--text-2);
|
||||||
cursor: pointer; font-size: 13px; white-space: nowrap;
|
cursor: pointer; font-size: 13px; white-space: nowrap;
|
||||||
transition: background var(--transition), color var(--transition);
|
transition: background var(--transition), color var(--transition);
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
.split-btn-main svg { flex-shrink: 0; }
|
.split-btn-main svg { flex-shrink: 0; }
|
||||||
|
|
||||||
.split-btn-drop {
|
.split-btn-drop {
|
||||||
display: flex; align-items: center; padding: 8px 6px;
|
display: flex; align-items: center; padding: var(--sp-2) 6px;
|
||||||
border-radius: 0 var(--radius) var(--radius) 0;
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
background: none; border: none; border-left: 1px solid var(--border);
|
background: none; border: none; border-left: 1px solid var(--border);
|
||||||
color: var(--text-3); cursor: pointer;
|
color: var(--text-3); cursor: pointer;
|
||||||
@@ -141,13 +141,13 @@
|
|||||||
display: none; position: absolute; left: 6px; right: 6px;
|
display: none; position: absolute; left: 6px; right: 6px;
|
||||||
top: 100%; z-index: 200;
|
top: 100%; z-index: 200;
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-light);
|
background: var(--bg-raised); border: 1px solid var(--border-light);
|
||||||
border-radius: var(--radius); padding: 4px;
|
border-radius: var(--radius); padding: var(--sp-1);
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
}
|
}
|
||||||
.split-dropdown.open { display: block; }
|
.split-dropdown.open { display: block; }
|
||||||
.split-dropdown-item {
|
.split-dropdown-item {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
padding: 7px 10px; border-radius: 4px; width: 100%;
|
padding: 7px var(--sp-3); border-radius: var(--radius-sm); width: 100%;
|
||||||
background: none; border: none; color: var(--text-2);
|
background: none; border: none; color: var(--text-2);
|
||||||
cursor: pointer; font-size: 12px; white-space: nowrap;
|
cursor: pointer; font-size: 12px; white-space: nowrap;
|
||||||
transition: background var(--transition), color var(--transition);
|
transition: background var(--transition), color var(--transition);
|
||||||
@@ -160,8 +160,8 @@
|
|||||||
|
|
||||||
|
|
||||||
.user-btn {
|
.user-btn {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
padding: 6px 8px; border-radius: var(--radius);
|
padding: var(--sp-2) var(--sp-2); border-radius: var(--radius);
|
||||||
background: none; border: none; color: var(--text);
|
background: none; border: none; color: var(--text);
|
||||||
cursor: pointer; width: 100%;
|
cursor: pointer; width: 100%;
|
||||||
transition: background var(--transition);
|
transition: background var(--transition);
|
||||||
@@ -198,12 +198,12 @@
|
|||||||
/* ── Sidebar Tabs (v0.21.6) ──────────── */
|
/* ── Sidebar Tabs (v0.21.6) ──────────── */
|
||||||
|
|
||||||
.sidebar-tabs {
|
.sidebar-tabs {
|
||||||
display: flex; padding: 0 8px; gap: 0;
|
display: flex; padding: 0 var(--sp-2); gap: 0;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.sidebar-tab {
|
.sidebar-tab {
|
||||||
flex: 1; padding: 6px 0; font-size: 12px; font-weight: 500;
|
flex: 1; padding: var(--sp-2) 0; font-size: 12px; font-weight: 500;
|
||||||
background: none; border: none; border-bottom: 2px solid transparent;
|
background: none; border: none; border-bottom: 2px solid transparent;
|
||||||
color: var(--text-3); cursor: pointer;
|
color: var(--text-3); cursor: pointer;
|
||||||
transition: color 0.15s, border-color 0.15s;
|
transition: color 0.15s, border-color 0.15s;
|
||||||
@@ -219,7 +219,7 @@
|
|||||||
|
|
||||||
.sidebar-search {
|
.sidebar-search {
|
||||||
display: flex; align-items: center; gap: 7px;
|
display: flex; align-items: center; gap: 7px;
|
||||||
padding: 6px 10px; margin: 8px 10px 4px;
|
padding: var(--sp-2) var(--sp-3); margin: var(--sp-2) var(--sp-3) var(--sp-1);
|
||||||
background: var(--input-bg); border: 1px solid var(--border);
|
background: var(--input-bg); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius); transition: border-color var(--transition);
|
border-radius: var(--radius); transition: border-color var(--transition);
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
.sidebar-search input::placeholder { color: var(--text-3); }
|
.sidebar-search input::placeholder { color: var(--text-3); }
|
||||||
.sidebar-search-clear {
|
.sidebar-search-clear {
|
||||||
background: none; border: none; color: var(--text-3); cursor: pointer;
|
background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||||
font-size: 11px; padding: 2px 4px; border-radius: 4px;
|
font-size: 11px; padding: 2px 4px; border-radius: var(--radius-sm);
|
||||||
transition: color var(--transition); display: none;
|
transition: color var(--transition); display: none;
|
||||||
}
|
}
|
||||||
.sidebar-search-clear:hover { color: var(--text); }
|
.sidebar-search-clear:hover { color: var(--text); }
|
||||||
@@ -251,7 +251,7 @@
|
|||||||
.mobile-menu-btn {
|
.mobile-menu-btn {
|
||||||
display: none; align-items: center; justify-content: center;
|
display: none; align-items: center; justify-content: center;
|
||||||
background: none; border: none; color: var(--text-2);
|
background: none; border: none; color: var(--text-2);
|
||||||
cursor: pointer; padding: 4px; border-radius: var(--radius);
|
cursor: pointer; padding: var(--sp-1); border-radius: var(--radius);
|
||||||
transition: color var(--transition);
|
transition: color var(--transition);
|
||||||
}
|
}
|
||||||
.mobile-menu-btn:hover { color: var(--text); }
|
.mobile-menu-btn:hover { color: var(--text); }
|
||||||
@@ -260,6 +260,11 @@
|
|||||||
background: var(--overlay);
|
background: var(--overlay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.workspace-secondary.open { width: 360px; min-width: 360px; }
|
||||||
|
.admin-sidebar { width: 120px; }
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar { position: fixed; z-index: 100; height: 100%; transition: width 0.2s ease; }
|
.sidebar { position: fixed; z-index: 100; height: 100%; transition: width 0.2s ease; }
|
||||||
.sidebar.collapsed { width: 0; border: none; overflow: hidden; }
|
.sidebar.collapsed { width: 0; border: none; overflow: hidden; }
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
position: fixed; inset: 0; background: var(--overlay);
|
position: fixed; inset: 0; background: var(--overlay);
|
||||||
display: none; align-items: center; justify-content: center;
|
display: none; align-items: center; justify-content: center;
|
||||||
z-index: 1000; backdrop-filter: blur(2px);
|
z-index: 1000; backdrop-filter: blur(2px);
|
||||||
padding: 2rem; /* keeps modal away from viewport edges at any zoom */
|
padding: var(--sp-8); /* keeps modal away from viewport edges at any zoom */
|
||||||
}
|
}
|
||||||
.modal-overlay.active { display: flex; }
|
.modal-overlay.active { display: flex; }
|
||||||
|
|
||||||
@@ -23,25 +23,25 @@
|
|||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 16px 20px; border-bottom: 1px solid var(--border);
|
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.modal-header h2 { font-size: 16px; font-weight: 600; }
|
.modal-header h2 { font-size: 16px; font-weight: 600; }
|
||||||
.modal-close {
|
.modal-close {
|
||||||
background: none; border: none; color: var(--text-3); font-size: 18px;
|
background: none; border: none; color: var(--text-3); font-size: 18px;
|
||||||
cursor: pointer; padding: 2px 6px; border-radius: 4px;
|
cursor: pointer; padding: 2px 6px; border-radius: var(--radius-sm);
|
||||||
transition: color var(--transition);
|
transition: color var(--transition);
|
||||||
}
|
}
|
||||||
.modal-close:hover { color: var(--text); }
|
.modal-close:hover { color: var(--text); }
|
||||||
.modal-body { flex: 1; overflow-y: auto; padding: 16px 20px; }
|
@media (max-width: 768px) {
|
||||||
|
.modal-close { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
}
|
||||||
|
.modal-body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5); }
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
padding: 12px 20px; border-top: 1px solid var(--border);
|
padding: var(--sp-3) var(--sp-5); border-top: 1px solid var(--border);
|
||||||
display: flex; justify-content: flex-end; gap: 0.5rem;
|
display: flex; justify-content: flex-end; gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); }
|
.avatar-upload-row { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-4); }
|
||||||
.settings-section:last-child { border-bottom: none; margin-bottom: 0; }
|
|
||||||
.settings-section h3 { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.06em; }
|
|
||||||
.avatar-upload-row { display: flex; align-items: center; gap: 14px; margin-bottom: 1rem; }
|
|
||||||
.avatar-preview {
|
.avatar-preview {
|
||||||
width: 56px; height: 56px; border-radius: 50%; background: var(--bg-raised);
|
width: 56px; height: 56px; border-radius: 50%; background: var(--bg-raised);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
overflow: hidden; border: 2px solid var(--border);
|
overflow: hidden; border: 2px solid var(--border);
|
||||||
}
|
}
|
||||||
.avatar-preview img { width: 100%; height: 100%; object-fit: cover; }
|
.avatar-preview img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
.avatar-actions { display: flex; flex-direction: column; gap: 4px; }
|
.avatar-actions { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||||
.avatar-actions .form-hint { font-size: 11px; margin-left: 0; }
|
.avatar-actions .form-hint { font-size: 11px; margin-left: 0; }
|
||||||
.avatar-preview-sm { width: 42px; height: 42px; font-size: 18px; }
|
.avatar-preview-sm { width: 42px; height: 42px; font-size: 18px; }
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
border-bottom: 1px solid var(--border); background: var(--bg-raised);
|
border-bottom: 1px solid var(--border); background: var(--bg-raised);
|
||||||
}
|
}
|
||||||
.modal-tabs {
|
.modal-tabs {
|
||||||
display: flex; padding: 0 16px;
|
display: flex; padding: 0 var(--sp-4);
|
||||||
border-bottom: 1px solid var(--border); background: var(--bg-raised);
|
border-bottom: 1px solid var(--border); background: var(--bg-raised);
|
||||||
overflow-x: auto; overflow-y: hidden;
|
overflow-x: auto; overflow-y: hidden;
|
||||||
scrollbar-width: none; -ms-overflow-style: none;
|
scrollbar-width: none; -ms-overflow-style: none;
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
/* Settings tabs */
|
/* Settings tabs */
|
||||||
.settings-tabs { gap: 0; }
|
.settings-tabs { gap: 0; }
|
||||||
.settings-tabs .settings-tab {
|
.settings-tabs .settings-tab {
|
||||||
padding: 10px 14px; background: none; border: none; border-radius: 0;
|
padding: var(--sp-3) var(--sp-3); background: none; border: none; border-radius: 0;
|
||||||
color: var(--text-3); cursor: pointer; font-size: 13px; font-family: var(--font);
|
color: var(--text-3); cursor: pointer; font-size: 13px; font-family: var(--font);
|
||||||
border-bottom: 2px solid transparent; transition: color var(--transition);
|
border-bottom: 2px solid transparent; transition: color var(--transition);
|
||||||
outline: none; -webkit-appearance: none; appearance: none;
|
outline: none; -webkit-appearance: none; appearance: none;
|
||||||
@@ -97,19 +97,19 @@
|
|||||||
|
|
||||||
.settings-notice {
|
.settings-notice {
|
||||||
background: rgba(234,179,8,0.08); border: 1px solid var(--warning-dim);
|
background: rgba(234,179,8,0.08); border: 1px solid var(--warning-dim);
|
||||||
border-radius: var(--radius); padding: 10px 14px; font-size: 13px;
|
border-radius: var(--radius); padding: var(--sp-3) var(--sp-3); font-size: 13px;
|
||||||
color: var(--warning); display: flex; align-items: center; gap: 8px;
|
color: var(--warning); display: flex; align-items: center; gap: var(--sp-2);
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Providers */
|
/* Providers */
|
||||||
.provider-row {
|
.provider-row {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 8px 0; border-bottom: 1px solid var(--border);
|
padding: var(--sp-2) 0; border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.provider-name { font-weight: 500; font-size: 13px; }
|
.provider-name { font-weight: 500; font-size: 13px; }
|
||||||
.provider-meta { font-size: 11px; color: var(--text-3); margin-left: 8px; }
|
.provider-meta { font-size: 11px; color: var(--text-3); margin-left: var(--sp-2); }
|
||||||
.provider-actions { display: flex; align-items: center; gap: 8px; }
|
.provider-actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
|
||||||
/* Admin panel (fullscreen) */
|
/* Admin panel (fullscreen) */
|
||||||
.admin-panel {
|
.admin-panel {
|
||||||
@@ -124,7 +124,7 @@
|
|||||||
}
|
}
|
||||||
.admin-back {
|
.admin-back {
|
||||||
background: none; border: none; color: var(--text-3); cursor: pointer;
|
background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||||
font-size: 14px; padding: 4px 10px; border-radius: 4px; font-family: var(--font);
|
font-size: 14px; padding: 4px 10px; border-radius: var(--radius-sm); font-family: var(--font);
|
||||||
}
|
}
|
||||||
.admin-back:hover { color: var(--accent); background: var(--bg-raised); }
|
.admin-back:hover { color: var(--accent); background: var(--bg-raised); }
|
||||||
.admin-title { font-size: 16px; font-weight: 600; margin-left: 1rem; flex: 1; }
|
.admin-title { font-size: 16px; font-weight: 600; margin-left: 1rem; flex: 1; }
|
||||||
@@ -147,18 +147,18 @@
|
|||||||
|
|
||||||
.admin-sidebar {
|
.admin-sidebar {
|
||||||
width: 160px; flex-shrink: 0; border-right: 1px solid var(--border);
|
width: 160px; flex-shrink: 0; border-right: 1px solid var(--border);
|
||||||
background: var(--bg-surface); padding: 0.75rem 0; overflow-y: auto;
|
background: var(--bg-surface); padding: var(--sp-3) 0; overflow-y: auto;
|
||||||
}
|
}
|
||||||
.admin-sidebar button {
|
.admin-sidebar button {
|
||||||
display: block; width: 100%; text-align: left; background: none; border: none;
|
display: block; width: 100%; text-align: left; background: none; border: none;
|
||||||
padding: 8px 20px; color: var(--text-3); cursor: pointer;
|
padding: var(--sp-2) var(--sp-5); color: var(--text-3); cursor: pointer;
|
||||||
font-size: 13px; font-family: var(--font); border-left: 3px solid transparent;
|
font-size: 13px; font-family: var(--font); border-left: 3px solid transparent;
|
||||||
transition: color 0.1s, background 0.1s;
|
transition: color 0.1s, background 0.1s;
|
||||||
}
|
}
|
||||||
.admin-sidebar button:hover { color: var(--text-2); background: var(--bg-raised); }
|
.admin-sidebar button:hover { color: var(--text-2); background: var(--bg-raised); }
|
||||||
.admin-sidebar button.active { color: var(--accent); border-left-color: var(--accent); background: var(--bg-raised); }
|
.admin-sidebar button.active { color: var(--accent); border-left-color: var(--accent); background: var(--bg-raised); }
|
||||||
|
|
||||||
.admin-main { flex: 1; overflow-y: auto; padding: 1.5rem 2rem; }
|
.admin-main { flex: 1; overflow-y: auto; padding: var(--sp-6) var(--sp-8); }
|
||||||
.admin-section-content { /* replaces .admin-tab-content */ }
|
.admin-section-content { /* replaces .admin-tab-content */ }
|
||||||
|
|
||||||
/* Admin shared */
|
/* Admin shared */
|
||||||
@@ -172,14 +172,14 @@
|
|||||||
.admin-tab:hover { color: var(--text-2); }
|
.admin-tab:hover { color: var(--text-2); }
|
||||||
.admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
.admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
|
|
||||||
.admin-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 1rem; }
|
.admin-toolbar { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-4); }
|
||||||
.admin-hint { font-size: 12px; color: var(--text-3); }
|
.admin-hint { font-size: 12px; color: var(--text-3); }
|
||||||
.admin-inline-form {
|
.admin-inline-form {
|
||||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
padding: 1rem; margin-bottom: 1rem;
|
padding: var(--sp-4); margin-bottom: var(--sp-4);
|
||||||
}
|
}
|
||||||
.ext-edit-form {
|
.ext-edit-form {
|
||||||
background: var(--bg); padding: 12px 0;
|
background: var(--bg); padding: var(--sp-3) 0;
|
||||||
}
|
}
|
||||||
.ext-edit-form textarea {
|
.ext-edit-form textarea {
|
||||||
resize: vertical; min-height: 60px;
|
resize: vertical; min-height: 60px;
|
||||||
@@ -194,17 +194,17 @@
|
|||||||
.admin-user-info { flex: 1; text-align: left; min-width: 0; }
|
.admin-user-info { flex: 1; text-align: left; min-width: 0; }
|
||||||
.admin-user-email { font-size: 12px; color: var(--text-3); margin-top: 2px; }
|
.admin-user-email { font-size: 12px; color: var(--text-3); margin-top: 2px; }
|
||||||
.admin-user-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
.admin-user-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||||
.admin-user-actions button { background: none; border: 1px solid var(--border); color: var(--text-2); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; min-width: 68px; text-align: center; }
|
.admin-user-actions button { background: none; border: 1px solid var(--border); color: var(--text-2); border-radius: var(--radius-sm); padding: 4px 10px; font-size: 12px; cursor: pointer; min-width: 68px; text-align: center; }
|
||||||
.admin-user-actions button:hover { border-color: var(--border-light); color: var(--text); }
|
.admin-user-actions button:hover { border-color: var(--border-light); color: var(--text); }
|
||||||
.admin-user-actions .btn-danger { color: var(--danger); }
|
.admin-user-actions .sw-btn--danger { color: var(--danger); }
|
||||||
.admin-user-actions .btn-danger:hover { border-color: var(--danger); }
|
.admin-user-actions .sw-btn--danger:hover { border-color: var(--danger); }
|
||||||
.inline-select { background: var(--bg-surface); border: 1px solid var(--border); color: var(--text-2); border-radius: 4px; padding: 3px 8px; font-size: 12px; cursor: pointer; }
|
.inline-select { background: var(--bg-surface); border: 1px solid var(--border); color: var(--text-2); border-radius: var(--radius-sm); padding: 3px 8px; font-size: 12px; cursor: pointer; }
|
||||||
|
|
||||||
.admin-model-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; min-height: 44px; }
|
.admin-model-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) 0; border-bottom: 1px solid var(--border); font-size: 14px; min-height: 44px; }
|
||||||
.admin-model-row .model-name { flex: 1; font-weight: 500; }
|
.admin-model-row .model-name { flex: 1; font-weight: 500; }
|
||||||
.admin-model-row .model-caps-inline { display: flex; gap: 3px; }
|
.admin-model-row .model-caps-inline { display: flex; gap: 3px; }
|
||||||
.admin-model-row .provider-meta { min-width: 80px; text-align: right; color: var(--text-3); font-size: 12px; }
|
.admin-model-row .provider-meta { min-width: 80px; text-align: right; color: var(--text-3); font-size: 12px; }
|
||||||
.admin-model-toggle { background: none; border: 1px solid var(--border); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; color: var(--text-2); min-width: 82px; text-align: center; }
|
.admin-model-toggle { background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 10px; font-size: 12px; cursor: pointer; color: var(--text-2); min-width: 82px; text-align: center; }
|
||||||
.admin-model-toggle:hover { border-color: var(--accent); color: var(--accent); }
|
.admin-model-toggle:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
.admin-model-toggle.enabled { border-color: var(--success); color: var(--success); }
|
.admin-model-toggle.enabled { border-color: var(--success); color: var(--success); }
|
||||||
.admin-model-toggle.team { border-color: var(--accent); color: var(--accent); }
|
.admin-model-toggle.team { border-color: var(--accent); color: var(--accent); }
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
.model-list-item.model-hidden .model-name { text-decoration: line-through; }
|
.model-list-item.model-hidden .model-name { text-decoration: line-through; }
|
||||||
|
|
||||||
.admin-provider-row {
|
.admin-provider-row {
|
||||||
display: flex; align-items: center; gap: 12px; padding: 10px 0;
|
display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) 0;
|
||||||
border-bottom: 1px solid var(--border); font-size: 14px;
|
border-bottom: 1px solid var(--border); font-size: 14px;
|
||||||
}
|
}
|
||||||
.admin-provider-row .provider-name { font-weight: 500; flex: 1; }
|
.admin-provider-row .provider-name { font-weight: 500; flex: 1; }
|
||||||
@@ -222,7 +222,7 @@
|
|||||||
.admin-provider-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; }
|
.admin-provider-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; }
|
||||||
.admin-provider-row .btn-delete:hover { color: var(--danger); }
|
.admin-provider-row .btn-delete:hover { color: var(--danger); }
|
||||||
|
|
||||||
.admin-persona-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
.admin-persona-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) 0; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||||
.admin-persona-row .persona-info { flex: 1; min-width: 0; }
|
.admin-persona-row .persona-info { flex: 1; min-width: 0; }
|
||||||
.persona-row-avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; vertical-align: middle; margin-right: 6px; }
|
.persona-row-avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; vertical-align: middle; margin-right: 6px; }
|
||||||
.persona-row-icon { margin-right: 4px; }
|
.persona-row-icon { margin-right: 4px; }
|
||||||
@@ -233,10 +233,10 @@
|
|||||||
.admin-persona-row .btn-delete:hover { color: var(--danger); }
|
.admin-persona-row .btn-delete:hover { color: var(--danger); }
|
||||||
|
|
||||||
/* Stats cards */
|
/* Stats cards */
|
||||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--sp-3); }
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
padding: 14px 16px; text-align: center;
|
padding: var(--sp-3) var(--sp-4); text-align: center;
|
||||||
}
|
}
|
||||||
.stat-card .stat-value { font-size: 1.6rem; font-weight: 700; color: var(--text); line-height: 1.2; }
|
.stat-card .stat-value { font-size: 1.6rem; font-weight: 700; color: var(--text); line-height: 1.2; }
|
||||||
.stat-card .stat-label { font-size: 0.78rem; color: var(--text-3); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.04em; }
|
.stat-card .stat-label { font-size: 0.78rem; color: var(--text-3); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
@@ -283,10 +283,10 @@
|
|||||||
.model-list-item .model-provider { font-size: 12px; color: var(--text-3); }
|
.model-list-item .model-provider { font-size: 12px; color: var(--text-3); }
|
||||||
.model-list-item .model-caps-inline { display: flex; gap: 3px; }
|
.model-list-item .model-caps-inline { display: flex; gap: 3px; }
|
||||||
|
|
||||||
.admin-approve-form { padding: 10px 12px; margin: -4px 0 8px; background: var(--glass); border-radius: 0 0 8px 8px; border-top: 1px solid var(--border); }
|
.admin-approve-form { padding: 10px 12px; margin: -4px 0 8px; background: var(--glass); border-radius: 0 0 var(--radius) var(--radius); border-top: 1px solid var(--border); }
|
||||||
.admin-approve-form .checkbox-label { display: block; margin: 2px 0; }
|
.admin-approve-form .checkbox-label { display: block; margin: 2px 0; }
|
||||||
.btn-approve { background: var(--success-dim); color: var(--success-light); border: 1px solid rgba(34,197,94,0.3); border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; }
|
.btn-approve { background: var(--success-dim); color: var(--success-light); border: 1px solid rgba(34,197,94,0.3); border-radius: var(--radius-sm); padding: 3px 10px; cursor: pointer; font-size: 12px; }
|
||||||
.team-card { padding: 8px 10px; background: var(--glass); border-radius: 6px; margin-bottom: 6px; }
|
.team-card { padding: 8px 10px; background: var(--glass); border-radius: var(--radius); margin-bottom: 6px; }
|
||||||
.team-card-info { display: flex; align-items: center; gap: 8px; }
|
.team-card-info { display: flex; align-items: center; gap: 8px; }
|
||||||
.team-admin-back {
|
.team-admin-back {
|
||||||
cursor: pointer; margin-right: 4px; opacity: 0.5;
|
cursor: pointer; margin-right: 4px; opacity: 0.5;
|
||||||
@@ -297,9 +297,9 @@
|
|||||||
.team-tab-content { padding: 0; }
|
.team-tab-content { padding: 0; }
|
||||||
|
|
||||||
/* Health + Routing admin (v0.22.3) */
|
/* Health + Routing admin (v0.22.3) */
|
||||||
.admin-health-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
|
.admin-health-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: var(--sp-3); }
|
||||||
.admin-health-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
|
.admin-health-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--sp-3); }
|
||||||
.list-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 8px; }
|
.list-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--sp-3); margin-bottom: var(--sp-2); }
|
||||||
.admin-user-row.user-inactive { opacity: 0.7; }
|
.admin-user-row.user-inactive { opacity: 0.7; }
|
||||||
|
|
||||||
|
|
||||||
@@ -324,8 +324,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.cmd-input-wrap {
|
.cmd-input-wrap {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.cmd-input-wrap svg { color: var(--text-3); flex-shrink: 0; }
|
.cmd-input-wrap svg { color: var(--text-3); flex-shrink: 0; }
|
||||||
.cmd-input-wrap input {
|
.cmd-input-wrap input {
|
||||||
@@ -336,17 +336,17 @@
|
|||||||
.cmd-kbd {
|
.cmd-kbd {
|
||||||
font-size: 10px; font-family: var(--mono); color: var(--text-3);
|
font-size: 10px; font-family: var(--mono); color: var(--text-3);
|
||||||
background: var(--bg); border: 1px solid var(--border);
|
background: var(--bg); border: 1px solid var(--border);
|
||||||
border-radius: 4px; padding: 2px 6px; flex-shrink: 0;
|
border-radius: var(--radius-sm); padding: 2px 6px; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cmd-results {
|
.cmd-results {
|
||||||
max-height: 320px; overflow-y: auto;
|
max-height: 320px; overflow-y: auto;
|
||||||
padding: 4px;
|
padding: var(--sp-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cmd-item {
|
.cmd-item {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
padding: 8px 12px; border-radius: var(--radius);
|
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
|
||||||
cursor: pointer; font-size: 13px; color: var(--text-2);
|
cursor: pointer; font-size: 13px; color: var(--text-2);
|
||||||
transition: background 60ms ease, color 60ms ease;
|
transition: background 60ms ease, color 60ms ease;
|
||||||
}
|
}
|
||||||
@@ -362,18 +362,18 @@
|
|||||||
.cmd-item.active .cmd-item-hint { color: var(--accent); }
|
.cmd-item.active .cmd-item-hint { color: var(--accent); }
|
||||||
.cmd-group-label {
|
.cmd-group-label {
|
||||||
font-size: 10px; font-weight: 600; color: var(--text-3);
|
font-size: 10px; font-weight: 600; color: var(--text-3);
|
||||||
padding: 8px 12px 4px; text-transform: uppercase; letter-spacing: 0.5px;
|
padding: var(--sp-2) var(--sp-3) var(--sp-1); text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cmd-footer {
|
.cmd-footer {
|
||||||
display: flex; align-items: center; gap: 16px;
|
display: flex; align-items: center; gap: var(--sp-4);
|
||||||
padding: 8px 16px; border-top: 1px solid var(--border);
|
padding: var(--sp-2) var(--sp-4); border-top: 1px solid var(--border);
|
||||||
font-size: 11px; color: var(--text-3);
|
font-size: 11px; color: var(--text-3);
|
||||||
}
|
}
|
||||||
.cmd-footer kbd {
|
.cmd-footer kbd {
|
||||||
font-size: 10px; font-family: var(--mono);
|
font-size: 10px; font-family: var(--mono);
|
||||||
background: var(--bg); border: 1px solid var(--border);
|
background: var(--bg); border: 1px solid var(--border);
|
||||||
border-radius: 3px; padding: 1px 4px;
|
border-radius: var(--radius-sm); padding: 1px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -393,15 +393,15 @@
|
|||||||
animation: confirm-in 0.15s ease;
|
animation: confirm-in 0.15s ease;
|
||||||
}
|
}
|
||||||
.confirm-header {
|
.confirm-header {
|
||||||
padding: 16px 20px 0; font-weight: 600; font-size: 15px; color: var(--text);
|
padding: var(--sp-4) var(--sp-5) 0; font-weight: 600; font-size: 15px; color: var(--text);
|
||||||
}
|
}
|
||||||
.confirm-body {
|
.confirm-body {
|
||||||
padding: 12px 20px 20px; font-size: 13px; color: var(--text-2);
|
padding: var(--sp-3) var(--sp-5) var(--sp-5); font-size: 13px; color: var(--text-2);
|
||||||
line-height: 1.5; white-space: pre-wrap;
|
line-height: 1.5; white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
.confirm-footer {
|
.confirm-footer {
|
||||||
display: flex; justify-content: flex-end; gap: 8px;
|
display: flex; justify-content: flex-end; gap: var(--sp-2);
|
||||||
padding: 0 20px 16px;
|
padding: 0 var(--sp-5) var(--sp-4);
|
||||||
}
|
}
|
||||||
@keyframes confirm-in { from { opacity: 0; transform: scale(0.95); } }
|
@keyframes confirm-in { from { opacity: 0; transform: scale(0.95); } }
|
||||||
@keyframes fade-in { from { opacity: 0; } }
|
@keyframes fade-in { from { opacity: 0; } }
|
||||||
@@ -409,7 +409,7 @@
|
|||||||
/* ── Group Chat persona picker (v0.23.0) ────── */
|
/* ── Group Chat persona picker (v0.23.0) ────── */
|
||||||
.group-persona-item {
|
.group-persona-item {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 6px 10px; border-radius: var(--radius);
|
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
|
||||||
cursor: pointer; font-size: 13px;
|
cursor: pointer; font-size: 13px;
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
@@ -429,21 +429,21 @@
|
|||||||
.sb-dialog {
|
.sb-dialog {
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-light);
|
background: var(--bg-raised); border: 1px solid var(--border-light);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 20px 22px 16px;
|
padding: var(--sp-5) var(--sp-5) var(--sp-4);
|
||||||
min-width: 300px; max-width: 420px; width: 90vw;
|
min-width: 300px; max-width: 420px; width: 90vw;
|
||||||
display: flex; flex-direction: column; gap: 12px;
|
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||||
animation: confirm-in 0.15s ease;
|
animation: confirm-in 0.15s ease;
|
||||||
}
|
}
|
||||||
.sb-dialog-title { font-size: 14px; font-weight: 600; color: var(--text); }
|
.sb-dialog-title { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||||
.sb-dialog-input {
|
.sb-dialog-input {
|
||||||
background: var(--bg-surface); border: 1px solid var(--border);
|
background: var(--bg-surface); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius); padding: 8px 10px;
|
border-radius: var(--radius); padding: var(--sp-2) var(--sp-3);
|
||||||
font-size: 13px; color: var(--text);
|
font-size: 13px; color: var(--text);
|
||||||
font-family: inherit; outline: none; width: 100%; box-sizing: border-box;
|
font-family: inherit; outline: none; width: 100%; box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.sb-dialog-input:focus { border-color: var(--accent); }
|
.sb-dialog-input:focus { border-color: var(--accent); }
|
||||||
.sb-dialog-color-row { display: flex; align-items: center; gap: 10px; }
|
.sb-dialog-color-row { display: flex; align-items: center; gap: var(--sp-3); }
|
||||||
.sb-dialog-color-label { font-size: 12px; color: var(--text-2); }
|
.sb-dialog-color-label { font-size: 12px; color: var(--text-2); }
|
||||||
.sb-dialog-color-input { width: 32px; height: 26px; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; padding: 1px; background: none; }
|
.sb-dialog-color-input { width: 32px; height: 26px; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; padding: 1px; background: none; }
|
||||||
.sb-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
.sb-dialog-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); }
|
||||||
|
|||||||
@@ -1,27 +1,11 @@
|
|||||||
/* ── primitives.css ──────────────────────────
|
/* ── primitives.css ──────────────────────────
|
||||||
Buttons, forms, toasts, popups, toggles, badges
|
Forms, toggles, badges, mode selector, REPL, SDK primitives.
|
||||||
|
Buttons/toasts/popup-menus migrated to sw-primitives.css (v0.6.11).
|
||||||
──────────────────────────────────────────── */
|
──────────────────────────────────────────── */
|
||||||
|
|
||||||
/* ── Buttons ─────────────────────────────── */
|
/* ── Buttons ─────────────────────────────── */
|
||||||
|
|
||||||
button { font-family: var(--font); cursor: pointer; }
|
button { font-family: var(--font); cursor: pointer; }
|
||||||
.btn-primary {
|
|
||||||
background: var(--accent); color: var(--text-on-color); border: none;
|
|
||||||
padding: 8px 16px; border-radius: var(--radius);
|
|
||||||
font-weight: 500; font-size: 13px;
|
|
||||||
transition: background var(--transition);
|
|
||||||
}
|
|
||||||
.btn-primary:hover { background: var(--accent-hover); }
|
|
||||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
.btn-full { width: 100%; }
|
|
||||||
.btn-small {
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border);
|
|
||||||
color: var(--text-2); padding: 6px 12px; border-radius: var(--radius);
|
|
||||||
font-size: 12px; transition: all var(--transition);
|
|
||||||
}
|
|
||||||
.btn-small:hover { background: var(--bg-hover); color: var(--text); }
|
|
||||||
.btn-small.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--text-on-color); }
|
|
||||||
.btn-danger { background: var(--danger); color: var(--text-on-color); border: none; border-radius: var(--radius); }
|
|
||||||
|
|
||||||
|
|
||||||
/* ── Forms ────────────────────────────────── */
|
/* ── Forms ────────────────────────────────── */
|
||||||
@@ -31,7 +15,7 @@ button { font-family: var(--font); cursor: pointer; }
|
|||||||
select, input[type="text"], input[type="email"], input[type="password"],
|
select, input[type="text"], input[type="email"], input[type="password"],
|
||||||
input[type="number"], input[type="search"], textarea {
|
input[type="number"], input[type="search"], textarea {
|
||||||
background: var(--bg-raised); border: 1px solid var(--border);
|
background: var(--bg-raised); border: 1px solid var(--border);
|
||||||
color: var(--text); padding: 8px 12px; border-radius: var(--radius);
|
color: var(--text); padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
|
||||||
font-family: var(--font); font-size: 13px;
|
font-family: var(--font); font-size: 13px;
|
||||||
transition: border-color var(--transition);
|
transition: border-color var(--transition);
|
||||||
}
|
}
|
||||||
@@ -41,12 +25,12 @@ input[type="password"]:focus, textarea:focus {
|
|||||||
}
|
}
|
||||||
select option { background: var(--bg-surface); color: var(--text); }
|
select option { background: var(--bg-surface); color: var(--text); }
|
||||||
|
|
||||||
.form-row { display: flex; gap: 0.75rem; }
|
.form-row { display: flex; gap: var(--sp-3); }
|
||||||
.form-row .form-group { flex: 1; }
|
.form-row .form-group { flex: 1; }
|
||||||
.form-hint { font-weight: 400; color: var(--text-3); margin-left: 4px; }
|
.form-hint { font-weight: 400; color: var(--text-3); margin-left: 4px; }
|
||||||
.range-input {
|
.range-input {
|
||||||
width: 100%; height: 4px; -webkit-appearance: none; appearance: none;
|
width: 100%; height: 4px; -webkit-appearance: none; appearance: none;
|
||||||
background: var(--border); border-radius: 2px; outline: none;
|
background: var(--border); border-radius: var(--radius-sm); outline: none;
|
||||||
margin: 8px 0 4px; cursor: pointer;
|
margin: 8px 0 4px; cursor: pointer;
|
||||||
}
|
}
|
||||||
.range-input::-webkit-slider-thumb {
|
.range-input::-webkit-slider-thumb {
|
||||||
@@ -61,20 +45,7 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); }
|
.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); }
|
||||||
|
|
||||||
|
|
||||||
/* ── Toast ────────────────────────────────── */
|
/* Toast system — see sw-primitives.css (.sw-toast) */
|
||||||
|
|
||||||
.toast-container { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); z-index: 10000; display: flex; flex-direction: column; gap: 0.5rem; }
|
|
||||||
.toast {
|
|
||||||
background: var(--bg-surface); border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius); padding: 8px 16px; font-size: 13px;
|
|
||||||
display: flex; align-items: center; gap: 8px;
|
|
||||||
animation: toast-in 0.25s ease; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
.toast.error { border-color: var(--danger); }
|
|
||||||
.toast.warning { border-color: var(--warning); }
|
|
||||||
.toast.success { border-color: var(--success); }
|
|
||||||
.toast button { background: none; border: none; color: var(--text-3); cursor: pointer; }
|
|
||||||
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } }
|
|
||||||
|
|
||||||
|
|
||||||
/* ── Mode Selector (v0.21.3) ───────────── */
|
/* ── Mode Selector (v0.21.3) ───────────── */
|
||||||
@@ -86,7 +57,7 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mode-selector {
|
.mode-selector {
|
||||||
display: flex; gap: 2px; padding: 6px 8px;
|
display: flex; gap: 2px; padding: var(--sp-2) var(--sp-2);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -110,16 +81,16 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
/* ── REPL Console (v0.21.3) ────────────── */
|
/* ── REPL Console (v0.21.3) ────────────── */
|
||||||
|
|
||||||
.repl-toolbar {
|
.repl-toolbar {
|
||||||
display: flex; gap: 0.5rem; padding: 6px 12px;
|
display: flex; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border); font-size: 11px;
|
border-bottom: 1px solid var(--border); font-size: 11px;
|
||||||
}
|
}
|
||||||
.repl-output {
|
.repl-output {
|
||||||
flex: 1; overflow-y: auto; padding: 6px;
|
flex: 1; overflow-y: auto; padding: var(--sp-2);
|
||||||
font-family: var(--mono); font-size: 12px; line-height: 1.6;
|
font-family: var(--mono); font-size: 12px; line-height: 1.6;
|
||||||
}
|
}
|
||||||
.repl-input-wrap {
|
.repl-input-wrap {
|
||||||
display: flex; align-items: flex-start; gap: 6px;
|
display: flex; align-items: flex-start; gap: 6px;
|
||||||
padding: 8px 12px; border-top: 1px solid var(--border);
|
padding: var(--sp-2) var(--sp-3); border-top: 1px solid var(--border);
|
||||||
background: var(--bg-raised);
|
background: var(--bg-raised);
|
||||||
}
|
}
|
||||||
.repl-prompt {
|
.repl-prompt {
|
||||||
@@ -136,7 +107,7 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
.repl-entry { padding: 2px 6px; }
|
.repl-entry { padding: 2px 6px; }
|
||||||
.repl-entry-input pre { color: var(--text-2); margin: 0; white-space: pre-wrap; word-break: break-all; }
|
.repl-entry-input pre { color: var(--text-2); margin: 0; white-space: pre-wrap; word-break: break-all; }
|
||||||
.repl-entry-result { color: var(--text); }
|
.repl-entry-result { color: var(--text); }
|
||||||
.repl-entry-error { color: var(--danger, #e55); }
|
.repl-entry-error { color: var(--danger); }
|
||||||
.repl-entry-info { color: var(--text-3); font-size: 11px; }
|
.repl-entry-info { color: var(--text-3); font-size: 11px; }
|
||||||
|
|
||||||
.repl-error-msg { margin: 0; white-space: pre-wrap; font-weight: 500; }
|
.repl-error-msg { margin: 0; white-space: pre-wrap; font-weight: 500; }
|
||||||
@@ -170,29 +141,7 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
.repl-json-toggle:hover { color: var(--accent); }
|
.repl-json-toggle:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
|
||||||
/* ── Popup Menu (shared base) ───────────── */
|
/* Popup menu — see sw-primitives.css (.sw-menu) */
|
||||||
|
|
||||||
.popup-menu {
|
|
||||||
display: none; position: absolute; z-index: 200;
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-light);
|
|
||||||
border-radius: var(--radius); padding: 4px;
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
|
||||||
min-width: 160px;
|
|
||||||
}
|
|
||||||
.popup-menu.open { display: block; }
|
|
||||||
.popup-menu-item {
|
|
||||||
display: flex; align-items: center; gap: 10px;
|
|
||||||
padding: 8px 12px; border-radius: var(--radius);
|
|
||||||
background: none; border: none; color: var(--text-2);
|
|
||||||
cursor: pointer; font-size: 13px; width: 100%;
|
|
||||||
transition: background var(--transition), color var(--transition);
|
|
||||||
}
|
|
||||||
.popup-menu-item:hover { background: var(--bg-hover); color: var(--text); }
|
|
||||||
.popup-menu-item.disabled { opacity: 0.4; pointer-events: none; }
|
|
||||||
.popup-menu-danger:hover { color: var(--danger); }
|
|
||||||
.popup-menu-icon { flex-shrink: 0; display: flex; align-items: center; }
|
|
||||||
.popup-menu-hint { margin-left: auto; font-size: 11px; color: var(--text-3); }
|
|
||||||
.popup-menu-divider { height: 1px; background: var(--border); margin: 4px 8px; }
|
|
||||||
|
|
||||||
/* ── Tools Toggle ────────────────────────── */
|
/* ── Tools Toggle ────────────────────────── */
|
||||||
|
|
||||||
@@ -200,7 +149,7 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
.popup {
|
.popup {
|
||||||
display: none; position: absolute;
|
display: none; position: absolute;
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-light);
|
background: var(--bg-raised); border: 1px solid var(--border-light);
|
||||||
border-radius: var(--radius-lg); padding: 4px;
|
border-radius: var(--radius-lg); padding: var(--sp-1);
|
||||||
box-shadow: var(--shadow-lg); z-index: 300;
|
box-shadow: var(--shadow-lg); z-index: 300;
|
||||||
}
|
}
|
||||||
.popup.open { display: block; }
|
.popup.open { display: block; }
|
||||||
@@ -214,8 +163,8 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
}
|
}
|
||||||
.tools-popup { bottom: 100%; left: 0; margin-bottom: 8px; min-width: 210px; }
|
.tools-popup { bottom: 100%; left: 0; margin-bottom: 8px; min-width: 210px; }
|
||||||
.tools-popup-category, .tools-popup-tool {
|
.tools-popup-category, .tools-popup-tool {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
padding: 8px 12px; border-radius: var(--radius);
|
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
|
||||||
cursor: pointer; font-size: 13px; color: var(--text-2);
|
cursor: pointer; font-size: 13px; color: var(--text-2);
|
||||||
transition: background var(--transition);
|
transition: background var(--transition);
|
||||||
}
|
}
|
||||||
@@ -266,17 +215,17 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
min-width: 240px; max-height: 300px; overflow-y: auto;
|
min-width: 240px; max-height: 300px; overflow-y: auto;
|
||||||
}
|
}
|
||||||
.kb-popup-header {
|
.kb-popup-header {
|
||||||
padding: 8px 12px 4px; font-size: 12px; font-weight: 600;
|
padding: var(--sp-2) var(--sp-3) var(--sp-1); font-size: 12px; font-weight: 600;
|
||||||
color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px;
|
color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
.kb-popup-item {
|
.kb-popup-item {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
padding: 6px 12px; cursor: pointer;
|
padding: var(--sp-2) var(--sp-3); cursor: pointer;
|
||||||
transition: background var(--transition);
|
transition: background var(--transition);
|
||||||
}
|
}
|
||||||
.kb-popup-item:hover { background: var(--bg-hover); }
|
.kb-popup-item:hover { background: var(--bg-hover); }
|
||||||
.kb-popup-item .kb-popup-check {
|
.kb-popup-item .kb-popup-check {
|
||||||
width: 16px; height: 16px; border-radius: 3px;
|
width: 16px; height: 16px; border-radius: var(--radius-sm);
|
||||||
border: 1.5px solid var(--border); flex-shrink: 0;
|
border: 1.5px solid var(--border); flex-shrink: 0;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
@@ -324,7 +273,7 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
|
|
||||||
/* ── Shared Form Controls ────────────────── */
|
/* ── Shared Form Controls ────────────────── */
|
||||||
|
|
||||||
.form-group { margin-bottom: 14px; }
|
.form-group { margin-bottom: var(--sp-3); }
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block; font-size: 12px; font-weight: 500;
|
display: block; font-size: 12px; font-weight: 500;
|
||||||
color: var(--text-2); margin-bottom: 5px;
|
color: var(--text-2); margin-bottom: 5px;
|
||||||
@@ -342,11 +291,11 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
|
|
||||||
.toggle-group {
|
.toggle-group {
|
||||||
display: flex; gap: 1px; padding: 3px;
|
display: flex; gap: 1px; padding: 3px;
|
||||||
background: var(--bg); border-radius: 10px;
|
background: var(--bg); border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--border); width: fit-content;
|
border: 1px solid var(--border); width: fit-content;
|
||||||
}
|
}
|
||||||
.toggle-btn {
|
.toggle-btn {
|
||||||
padding: 6px 14px; border: none; border-radius: 7px;
|
padding: 6px 14px; border: none; border-radius: var(--radius);
|
||||||
background: transparent; color: var(--text-3);
|
background: transparent; color: var(--text-3);
|
||||||
cursor: pointer; font-size: 12px; font-weight: 500;
|
cursor: pointer; font-size: 12px; font-weight: 500;
|
||||||
font-family: inherit; transition: all 0.15s;
|
font-family: inherit; transition: all 0.15s;
|
||||||
@@ -354,15 +303,11 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
.toggle-btn.active { background: var(--bg-raised); color: var(--text); }
|
.toggle-btn.active { background: var(--bg-raised); color: var(--text); }
|
||||||
.toggle-btn:hover { color: var(--text-2); }
|
.toggle-btn:hover { color: var(--text-2); }
|
||||||
|
|
||||||
.btn-ghost { background: transparent; color: var(--text-2); border: 1px solid var(--border); }
|
/* Button variants — see sw-primitives.css (.sw-btn--*) */
|
||||||
.btn-ghost:hover { background: var(--bg-hover); }
|
|
||||||
.btn-subtle { background: var(--accent-dim); color: var(--accent); border: none; }
|
|
||||||
.btn-sm { padding: 5px 11px; font-size: 12px; border-radius: 6px; cursor: pointer; font-weight: 500; font-family: inherit; }
|
|
||||||
.btn-md { padding: 7px 15px; font-size: 13px; border-radius: var(--radius); cursor: pointer; font-weight: 500; font-family: inherit; }
|
|
||||||
|
|
||||||
/* ── Badge ───────────────────────────────── */
|
/* ── Badge ───────────────────────────────── */
|
||||||
.badge {
|
.badge {
|
||||||
font-size: 10px; padding: 2px 8px; border-radius: 4px;
|
font-size: 10px; padding: 2px 8px; border-radius: var(--radius-sm);
|
||||||
font-weight: 600; letter-spacing: 0.3px; white-space: nowrap;
|
font-weight: 600; letter-spacing: 0.3px; white-space: nowrap;
|
||||||
display: inline-flex; align-items: center;
|
display: inline-flex; align-items: center;
|
||||||
}
|
}
|
||||||
@@ -391,7 +336,7 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
/* ── Icon Button ─────────────────────────── */
|
/* ── Icon Button ─────────────────────────── */
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
background: none; border: none; color: var(--text-3);
|
background: none; border: none; color: var(--text-3);
|
||||||
cursor: pointer; padding: 5px; border-radius: 7px;
|
cursor: pointer; padding: var(--sp-1); border-radius: var(--radius);
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
@@ -400,9 +345,9 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
.icon-btn-danger:hover { color: var(--danger); }
|
.icon-btn-danger:hover { color: var(--danger); }
|
||||||
|
|
||||||
/* ── Utility States ─────────────────────── */
|
/* ── Utility States ─────────────────────── */
|
||||||
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
|
.loading { color: var(--text-3); font-size: 13px; padding: var(--sp-2); }
|
||||||
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }
|
.error-hint { color: var(--danger); font-size: 13px; padding: var(--sp-2); }
|
||||||
.empty-hint { color: var(--text-3); font-size: 13px; text-align: center; padding: 1rem; }
|
.empty-hint { color: var(--text-3); font-size: 13px; text-align: center; padding: var(--sp-4); }
|
||||||
|
|
||||||
/* ── SDK Primitives (sw.menu, sw.dropdown, sw.toolbar, sw.tabs) ── */
|
/* ── SDK Primitives (sw.menu, sw.dropdown, sw.toolbar, sw.tabs) ── */
|
||||||
|
|
||||||
@@ -410,8 +355,8 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
.sw-menu-wrap { position: relative; display: inline-flex; }
|
.sw-menu-wrap { position: relative; display: inline-flex; }
|
||||||
.sw-menu-flyout {
|
.sw-menu-flyout {
|
||||||
display: none; position: absolute; right: 0; left: auto; z-index: 200;
|
display: none; position: absolute; right: 0; left: auto; z-index: 200;
|
||||||
background: var(--bg-elevated, #42424e); border: 1px solid var(--border-elevated, #555);
|
background: var(--bg-elevated); border: 1px solid var(--border-elevated);
|
||||||
border-radius: var(--radius-lg, 8px); padding: 4px; min-width: 170px;
|
border-radius: var(--radius-lg); padding: 4px; min-width: 170px;
|
||||||
box-shadow: 0 8px 40px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.08);
|
box-shadow: 0 8px 40px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.08);
|
||||||
}
|
}
|
||||||
.sw-menu-flyout[data-position="down"] { top: 100%; margin-top: 4px; }
|
.sw-menu-flyout[data-position="down"] { top: 100%; margin-top: 4px; }
|
||||||
@@ -421,63 +366,40 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
.sw-menu-flyout[data-fixed] { position: fixed; }
|
.sw-menu-flyout[data-fixed] { position: fixed; }
|
||||||
/* Flyout items — single source of truth for all flyout menus (sw.menu + UserMenu) */
|
/* Flyout items — single source of truth for all flyout menus (sw.menu + UserMenu) */
|
||||||
.sw-menu-flyout .flyout-item {
|
.sw-menu-flyout .flyout-item {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
padding: 8px 12px; border-radius: var(--radius, 6px);
|
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
|
||||||
background: none; border: none; color: var(--text, #e8e8ed);
|
background: none; border: none; color: var(--text);
|
||||||
cursor: pointer; font-size: 13px; font-family: inherit; width: 100%;
|
cursor: pointer; font-size: 13px; font-family: inherit; width: 100%;
|
||||||
transition: background 0.15s, color 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.sw-menu-flyout .flyout-item:hover {
|
.sw-menu-flyout .flyout-item:hover {
|
||||||
background: var(--bg-hover, rgba(255,255,255,0.06)); color: var(--text, #eee);
|
background: var(--bg-hover); color: var(--text);
|
||||||
}
|
}
|
||||||
.sw-menu-flyout .flyout-item svg { flex-shrink: 0; opacity: 0.8; }
|
.sw-menu-flyout .flyout-item svg { flex-shrink: 0; opacity: 0.8; }
|
||||||
.sw-menu-flyout .flyout-danger { color: var(--danger, #f44336); }
|
.sw-menu-flyout .flyout-danger { color: var(--danger); }
|
||||||
.sw-menu-flyout .flyout-danger:hover {
|
.sw-menu-flyout .flyout-danger:hover {
|
||||||
background: var(--danger-dim, rgba(244,67,54,0.1)); color: var(--danger, #f44336);
|
background: var(--danger-dim); color: var(--danger);
|
||||||
}
|
}
|
||||||
.sw-menu-flyout .flyout-divider {
|
.sw-menu-flyout .flyout-divider {
|
||||||
border: none; height: 1px; background: var(--border, #2a2a2e); margin: 4px 0;
|
border: none; height: 1px; background: var(--border); margin: var(--sp-1) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dropdown — styled native select */
|
/* Dropdown — see sw-primitives.css (.sw-dropdown) */
|
||||||
.sw-dropdown {
|
|
||||||
background: var(--bg-raised, #222); color: var(--text, #eee);
|
|
||||||
border: 1px solid var(--border, #2e2e35); border-radius: var(--radius, 6px);
|
|
||||||
padding: 6px 10px; font-size: 13px; font-family: inherit;
|
|
||||||
cursor: pointer; min-width: 100px;
|
|
||||||
}
|
|
||||||
.sw-dropdown:hover { border-color: var(--border-light, #444); }
|
|
||||||
.sw-dropdown:focus { outline: none; border-color: var(--accent, #b38a4e); }
|
|
||||||
|
|
||||||
/* Toolbar — horizontal button row */
|
/* Toolbar — horizontal button row */
|
||||||
.sw-toolbar {
|
.sw-toolbar {
|
||||||
display: flex; align-items: center; gap: 4px;
|
display: flex; align-items: center; gap: var(--sp-1);
|
||||||
padding: 4px 0;
|
padding: var(--sp-1) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tabs — tabbed content container */
|
/* Tabs — see sw-primitives.css (.sw-tabs) */
|
||||||
.sw-tabs { display: flex; flex-direction: column; height: 100%; }
|
|
||||||
.sw-tabs-bar {
|
|
||||||
display: flex; gap: 0; border-bottom: 1px solid var(--border, #2e2e35);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.sw-tab-btn {
|
|
||||||
padding: 8px 16px; background: none; border: none; border-bottom: 2px solid transparent;
|
|
||||||
color: var(--text-2, #999); cursor: pointer; font-size: 12px; font-weight: 600;
|
|
||||||
font-family: inherit; text-transform: uppercase; letter-spacing: 0.5px;
|
|
||||||
transition: color 0.15s, border-color 0.15s;
|
|
||||||
}
|
|
||||||
.sw-tab-btn:hover { color: var(--text, #eee); }
|
|
||||||
.sw-tab-btn--active { color: var(--accent, #b38a4e); border-bottom-color: var(--accent, #b38a4e); }
|
|
||||||
.sw-tabs-content { flex: 1; min-height: 0; overflow: hidden; }
|
|
||||||
.sw-tab-panel { height: 100%; overflow-y: auto; }
|
|
||||||
|
|
||||||
/* ── Responsive: Mobile ───────────────────── */
|
/* ── Responsive: Mobile ───────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
/* Flyout items: larger touch targets */
|
/* Flyout items: larger touch targets */
|
||||||
.sw-menu-flyout .flyout-item {
|
.sw-menu-flyout .flyout-item {
|
||||||
padding: 10px 12px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,6 +413,7 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
width: calc(100vw - 32px);
|
width: calc(100vw - 32px);
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: calc(100vh - 64px);
|
max-height: calc(100vh - 64px);
|
||||||
|
max-height: calc(100dvh - 64px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Toast: full width, centered */
|
/* Toast: full width, centered */
|
||||||
@@ -507,17 +430,17 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
|
|
||||||
.sw-user-picker__input {
|
.sw-user-picker__input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 10px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
border-radius: var(--radius, 6px);
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
background: var(--bg, #0e0e10);
|
background: var(--bg);
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-user-picker__input:focus {
|
.sw-user-picker__input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent, #b38a4e);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-user-picker__spinner {
|
.sw-user-picker__spinner {
|
||||||
@@ -527,8 +450,8 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border: 2px solid var(--border, #2a2a2e);
|
border: 2px solid var(--border);
|
||||||
border-top-color: var(--accent, #b38a4e);
|
border-top-color: var(--accent);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: sw-spin 0.6s linear infinite;
|
animation: sw-spin 0.6s linear infinite;
|
||||||
}
|
}
|
||||||
@@ -541,9 +464,9 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
background: var(--bg-elevated, #2a2a2e);
|
background: var(--bg-elevated);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius, 6px);
|
border-radius: var(--radius);
|
||||||
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
|
||||||
z-index: 300;
|
z-index: 300;
|
||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
@@ -553,22 +476,22 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
.sw-user-picker__option {
|
.sw-user-picker__option {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
padding: 8px 10px;
|
padding: var(--sp-2) var(--sp-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-user-picker__option:hover,
|
.sw-user-picker__option:hover,
|
||||||
.sw-user-picker__option--active {
|
.sw-user-picker__option--active {
|
||||||
background: var(--bg-hover, rgba(255,255,255,0.06));
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-user-picker__avatar {
|
.sw-user-picker__avatar {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent, #b38a4e);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -586,11 +509,11 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
|
|
||||||
.sw-user-picker__name {
|
.sw-user-picker__name {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-user-picker__handle {
|
.sw-user-picker__handle {
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
color: var(--text-muted, #888);
|
color: var(--text-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,13 @@
|
|||||||
|
|
||||||
.admin-storage-card {
|
.admin-storage-card {
|
||||||
background: var(--bg-raised); border: 1px solid var(--border);
|
background: var(--bg-raised); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius); padding: 16px; margin-bottom: 12px;
|
border-radius: var(--radius); padding: var(--sp-4); margin-bottom: var(--sp-3);
|
||||||
}
|
}
|
||||||
.admin-storage-card h4 { margin: 0 0 10px 0; font-size: 14px; }
|
.admin-storage-card h4 { margin: 0 0 var(--sp-3) 0; font-size: 14px; }
|
||||||
.admin-storage-desc { font-size: 12px; color: var(--text-3); margin: 0 0 10px 0; }
|
.admin-storage-desc { font-size: 12px; color: var(--text-3); margin: 0 0 var(--sp-3) 0; }
|
||||||
.admin-storage-grid {
|
.admin-storage-grid {
|
||||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
}
|
}
|
||||||
.admin-storage-item { display: flex; flex-direction: column; gap: 2px; }
|
.admin-storage-item { display: flex; flex-direction: column; gap: 2px; }
|
||||||
.admin-storage-label { font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; }
|
.admin-storage-label { font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
@@ -31,15 +31,15 @@
|
|||||||
display: flex; height: 100%; overflow: hidden;
|
display: flex; height: 100%; overflow: hidden;
|
||||||
}
|
}
|
||||||
.settings-topbar {
|
.settings-topbar {
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
padding: 10px 20px; border-bottom: 1px solid var(--border);
|
padding: var(--sp-3) var(--sp-5); border-bottom: 1px solid var(--border);
|
||||||
background: var(--bg-surface); flex-shrink: 0;
|
background: var(--bg-surface); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.settings-topbar-back {
|
.settings-topbar-back {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
background: none; border: none; color: var(--text-3);
|
background: none; border: none; color: var(--text-3);
|
||||||
cursor: pointer; font-family: inherit; font-size: 13px;
|
cursor: pointer; font-family: inherit; font-size: 13px;
|
||||||
padding: 4px 8px; border-radius: 6px; transition: color 0.12s;
|
padding: 4px 8px; border-radius: var(--radius); transition: color 0.12s;
|
||||||
}
|
}
|
||||||
.settings-topbar-back:hover { color: var(--text); }
|
.settings-topbar-back:hover { color: var(--text); }
|
||||||
.settings-topbar-sep { width: 1px; height: 20px; background: var(--border); }
|
.settings-topbar-sep { width: 1px; height: 20px; background: var(--border); }
|
||||||
@@ -48,12 +48,12 @@
|
|||||||
width: 220px; flex-shrink: 0;
|
width: 220px; flex-shrink: 0;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
padding: 12px 8px; overflow-y: auto;
|
padding: var(--sp-3) var(--sp-2); overflow-y: auto;
|
||||||
display: flex; flex-direction: column; gap: 2px;
|
display: flex; flex-direction: column; gap: 2px;
|
||||||
}
|
}
|
||||||
.settings-nav-link {
|
.settings-nav-link {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
padding: 8px 12px; border-radius: 8px;
|
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
|
||||||
color: var(--text-2); text-decoration: none;
|
color: var(--text-2); text-decoration: none;
|
||||||
font-size: 13px; font-weight: 500; transition: all 0.12s;
|
font-size: 13px; font-weight: 500; transition: all 0.12s;
|
||||||
border: none; background: none; cursor: pointer;
|
border: none; background: none; cursor: pointer;
|
||||||
@@ -61,28 +61,28 @@
|
|||||||
}
|
}
|
||||||
.settings-nav-link:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; }
|
.settings-nav-link:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; }
|
||||||
.settings-nav-link.active { background: var(--accent-dim); color: var(--accent); }
|
.settings-nav-link.active { background: var(--accent-dim); color: var(--accent); }
|
||||||
.settings-nav-sep { margin: 8px 0; border-top: 1px solid var(--border); }
|
.settings-nav-sep { margin: var(--sp-2) 0; border-top: 1px solid var(--border); }
|
||||||
.settings-content { flex: 1; overflow-y: auto; padding: 28px; }
|
.settings-content { flex: 1; overflow-y: auto; padding: var(--sp-8); }
|
||||||
.settings-content h2 { font-size: 18px; font-weight: 600; margin: 0 0 20px; }
|
.settings-content h2 { font-size: 18px; font-weight: 600; margin: 0 0 var(--sp-5); }
|
||||||
.settings-content .settings-section {
|
.settings-content .settings-section {
|
||||||
background: none; border: none; border-radius: 0;
|
background: none; border: none; border-radius: 0;
|
||||||
padding: 0 0 16px; margin-bottom: 20px; max-width: 560px;
|
padding: 0 0 var(--sp-4); margin-bottom: var(--sp-5); max-width: 560px;
|
||||||
}
|
}
|
||||||
.settings-content .settings-section h3 {
|
.settings-content .settings-section h3 {
|
||||||
font-size: 14px; font-weight: 600; margin: 0 0 14px;
|
font-size: 14px; font-weight: 600; margin: 0 0 var(--sp-3);
|
||||||
padding-bottom: 8px; border-bottom: 1px solid var(--border);
|
padding-bottom: var(--sp-2); border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.settings-section {
|
.settings-section {
|
||||||
margin-bottom: 24px; padding: 20px;
|
margin-bottom: var(--sp-6); padding: var(--sp-5);
|
||||||
background: var(--bg-surface); border-radius: 10px;
|
background: var(--bg-surface); border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.settings-section h3 {
|
.settings-section h3 {
|
||||||
font-size: 15px; font-weight: 600; margin: 0 0 16px;
|
font-size: 15px; font-weight: 600; margin: 0 0 var(--sp-4);
|
||||||
padding-bottom: 10px; border-bottom: 1px solid var(--border);
|
padding-bottom: var(--sp-3); border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.settings-placeholder {
|
.settings-placeholder {
|
||||||
padding: 40px 20px; color: var(--text-3);
|
padding: var(--sp-10) var(--sp-5); color: var(--text-3);
|
||||||
text-align: center; font-size: 13px;
|
text-align: center; font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,27 +92,27 @@
|
|||||||
display: flex; flex-direction: column; height: 100%; overflow: hidden;
|
display: flex; flex-direction: column; height: 100%; overflow: hidden;
|
||||||
}
|
}
|
||||||
.admin-topbar {
|
.admin-topbar {
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
padding: 10px 20px; border-bottom: 1px solid var(--border);
|
padding: var(--sp-3) var(--sp-5); border-bottom: 1px solid var(--border);
|
||||||
background: var(--bg-surface); flex-shrink: 0;
|
background: var(--bg-surface); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.admin-topbar-back {
|
.admin-topbar-back {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
background: none; border: none; color: var(--text-3);
|
background: none; border: none; color: var(--text-3);
|
||||||
cursor: pointer; font-family: inherit; font-size: 13px;
|
cursor: pointer; font-family: inherit; font-size: 13px;
|
||||||
padding: 4px 8px; border-radius: 6px; transition: color 0.12s;
|
padding: 4px 8px; border-radius: var(--radius); transition: color 0.12s;
|
||||||
}
|
}
|
||||||
.admin-topbar-back:hover { color: var(--text); }
|
.admin-topbar-back:hover { color: var(--text); }
|
||||||
.admin-topbar-sep { width: 1px; height: 20px; background: var(--border); }
|
.admin-topbar-sep { width: 1px; height: 20px; background: var(--border); }
|
||||||
.admin-topbar-title { font-size: 15px; font-weight: 600; }
|
.admin-topbar-title { font-size: 15px; font-weight: 600; }
|
||||||
.admin-category-tabs {
|
.admin-category-tabs {
|
||||||
display: flex; gap: 1px; padding: 3px;
|
display: flex; gap: 1px; padding: 3px;
|
||||||
background: var(--bg); border-radius: 10px;
|
background: var(--bg); border-radius: var(--radius-lg);
|
||||||
border: 1px solid var(--border); margin-left: auto;
|
border: 1px solid var(--border); margin-left: auto;
|
||||||
}
|
}
|
||||||
.admin-cat-btn {
|
.admin-cat-btn {
|
||||||
display: flex; align-items: center; gap: 6px;
|
display: flex; align-items: center; gap: 6px;
|
||||||
padding: 6px 14px; border-radius: 7px; border: none;
|
padding: 6px 14px; border-radius: var(--radius); border: none;
|
||||||
cursor: pointer; font-size: 13px; font-weight: 500;
|
cursor: pointer; font-size: 13px; font-weight: 500;
|
||||||
font-family: inherit; background: transparent; color: var(--text-3);
|
font-family: inherit; background: transparent; color: var(--text-3);
|
||||||
transition: all 0.12s;
|
transition: all 0.12s;
|
||||||
@@ -123,10 +123,10 @@
|
|||||||
.admin-nav {
|
.admin-nav {
|
||||||
width: 200px; background: var(--bg-surface);
|
width: 200px; background: var(--bg-surface);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
padding: 12px 8px; display: flex; flex-direction: column; gap: 2px; flex-shrink: 0;
|
padding: var(--sp-3) var(--sp-2); display: flex; flex-direction: column; gap: 2px; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.admin-nav-link {
|
.admin-nav-link {
|
||||||
display: flex; padding: 8px 12px; border-radius: 8px;
|
display: flex; padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
|
||||||
border: none; cursor: pointer; font-family: inherit;
|
border: none; cursor: pointer; font-family: inherit;
|
||||||
font-size: 13px; font-weight: 500; width: 100%;
|
font-size: 13px; font-weight: 500; width: 100%;
|
||||||
text-align: left; transition: all 0.12s;
|
text-align: left; transition: all 0.12s;
|
||||||
@@ -137,30 +137,30 @@
|
|||||||
.admin-nav-link.active { background: var(--accent-dim); color: var(--accent); }
|
.admin-nav-link.active { background: var(--accent-dim); color: var(--accent); }
|
||||||
.admin-content { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
.admin-content { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||||
.admin-content-header {
|
.admin-content-header {
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
padding: 14px 24px; border-bottom: 1px solid var(--border); flex-shrink: 0;
|
padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.admin-content-header h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
.admin-content-header h2 { font-size: 16px; font-weight: 600; margin: 0; }
|
||||||
.admin-search {
|
.admin-search {
|
||||||
display: flex; align-items: center; gap: 7px;
|
display: flex; align-items: center; gap: 7px;
|
||||||
background: var(--input-bg, var(--bg)); border-radius: 8px;
|
background: var(--input-bg, var(--bg)); border-radius: var(--radius);
|
||||||
padding: 6px 10px; border: 1px solid var(--border); width: 220px;
|
padding: var(--sp-2) var(--sp-3); border: 1px solid var(--border); width: 220px;
|
||||||
}
|
}
|
||||||
.admin-search input {
|
.admin-search input {
|
||||||
background: none; border: none; color: var(--text);
|
background: none; border: none; color: var(--text);
|
||||||
font-size: 12px; font-family: inherit; flex: 1; outline: none;
|
font-size: 12px; font-family: inherit; flex: 1; outline: none;
|
||||||
}
|
}
|
||||||
.admin-content-body { flex: 1; overflow-y: auto; padding: 24px; }
|
.admin-content-body { flex: 1; overflow-y: auto; padding: var(--sp-6); }
|
||||||
|
|
||||||
/* ── Data Tables ─────────────────────────── */
|
/* ── Data Tables ─────────────────────────── */
|
||||||
.data-table { width: 100%; border-collapse: collapse; }
|
.data-table { width: 100%; border-collapse: collapse; }
|
||||||
.data-table th {
|
.data-table th {
|
||||||
padding: 10px 14px; text-align: left; font-weight: 600;
|
padding: var(--sp-3) var(--sp-3); text-align: left; font-weight: 600;
|
||||||
font-size: 11px; color: var(--text-3); text-transform: uppercase;
|
font-size: 11px; color: var(--text-3); text-transform: uppercase;
|
||||||
letter-spacing: 0.5px; border-bottom: 1px solid var(--border);
|
letter-spacing: 0.5px; border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.data-table td {
|
.data-table td {
|
||||||
padding: 10px 14px; font-size: 13px;
|
padding: var(--sp-3) var(--sp-3); font-size: 13px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.data-table tr:hover td { background: var(--bg-hover); }
|
.data-table tr:hover td { background: var(--bg-hover); }
|
||||||
@@ -171,15 +171,15 @@
|
|||||||
display: flex; flex-direction: column; height: 100%; overflow: hidden;
|
display: flex; flex-direction: column; height: 100%; overflow: hidden;
|
||||||
}
|
}
|
||||||
.editor-topbar {
|
.editor-topbar {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
padding: 6px 12px; border-bottom: 1px solid var(--border);
|
padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border);
|
||||||
background: var(--bg-surface); flex-shrink: 0;
|
background: var(--bg-surface); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.editor-topbar-back {
|
.editor-topbar-back {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
background: none; border: none; color: var(--text-3);
|
background: none; border: none; color: var(--text-3);
|
||||||
cursor: pointer; font-family: inherit; font-size: 13px;
|
cursor: pointer; font-family: inherit; font-size: 13px;
|
||||||
padding: 4px 8px; border-radius: 6px;
|
padding: 4px 8px; border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
.editor-topbar-back:hover { color: var(--text); }
|
.editor-topbar-back:hover { color: var(--text); }
|
||||||
.editor-body { display: flex; flex: 1; min-height: 0; }
|
.editor-body { display: flex; flex: 1; min-height: 0; }
|
||||||
@@ -189,10 +189,10 @@
|
|||||||
display: flex; flex-direction: column; flex-shrink: 0;
|
display: flex; flex-direction: column; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.editor-tree-header {
|
.editor-tree-header {
|
||||||
padding: 8px 10px; border-bottom: 1px solid var(--border);
|
padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border);
|
||||||
display: flex; align-items: center; gap: 6px;
|
display: flex; align-items: center; gap: 6px;
|
||||||
}
|
}
|
||||||
.editor-tree-files { flex: 1; overflow-y: auto; padding: 4px 0; }
|
.editor-tree-files { flex: 1; overflow-y: auto; padding: var(--sp-1) 0; }
|
||||||
.editor-main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
.editor-main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||||
.editor-tabs {
|
.editor-tabs {
|
||||||
display: flex; align-items: center;
|
display: flex; align-items: center;
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
}
|
}
|
||||||
.editor-tab {
|
.editor-tab {
|
||||||
display: flex; align-items: center; gap: 6px;
|
display: flex; align-items: center; gap: 6px;
|
||||||
padding: 6px 12px; cursor: pointer; font-size: 12px;
|
padding: var(--sp-2) var(--sp-3); cursor: pointer; font-size: 12px;
|
||||||
white-space: nowrap; border-right: 1px solid var(--border);
|
white-space: nowrap; border-right: 1px solid var(--border);
|
||||||
background: transparent; color: var(--text-3);
|
background: transparent; color: var(--text-3);
|
||||||
border-bottom: 2px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
@@ -214,27 +214,27 @@
|
|||||||
}
|
}
|
||||||
.editor-content { flex: 1; overflow: auto; }
|
.editor-content { flex: 1; overflow: auto; }
|
||||||
.editor-statusbar {
|
.editor-statusbar {
|
||||||
display: flex; align-items: center; gap: 14px;
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
padding: 3px 12px; background: var(--bg-surface);
|
padding: 3px var(--sp-3); background: var(--bg-surface);
|
||||||
border-top: 1px solid var(--border); flex-shrink: 0;
|
border-top: 1px solid var(--border); flex-shrink: 0;
|
||||||
font-size: 11px; color: var(--text-3);
|
font-size: 11px; color: var(--text-3);
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
}
|
}
|
||||||
/* ── Stat Cards Grid ──────────────────────── */
|
/* ── Stat Cards Grid ──────────────────────── */
|
||||||
.stat-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; }
|
.stat-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: var(--sp-3); margin-bottom: var(--sp-6); }
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px;
|
background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: var(--sp-4);
|
||||||
}
|
}
|
||||||
.stat-card-label { font-size: 11px; color: var(--text-3); font-weight: 500; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.04em; }
|
.stat-card-label { font-size: 11px; color: var(--text-3); font-weight: 500; margin-bottom: var(--sp-2); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
.stat-card-value { font-size: 24px; font-weight: 700; line-height: 1; }
|
.stat-card-value { font-size: 24px; font-weight: 700; line-height: 1; }
|
||||||
.stat-card-sub { font-size: 11px; color: var(--text-3); margin-top: 4px; }
|
.stat-card-sub { font-size: 11px; color: var(--text-3); margin-top: 4px; }
|
||||||
|
|
||||||
/* ── Bar Chart ───────────────────────────── */
|
/* ── Bar Chart ───────────────────────────── */
|
||||||
.bar-chart { display: flex; align-items: flex-end; gap: 8px; height: 140px; }
|
.bar-chart { display: flex; align-items: flex-end; gap: var(--sp-2); height: 140px; }
|
||||||
.bar-chart-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
.bar-chart-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; }
|
||||||
.bar-chart-val { font-size: 10px; color: var(--text-3); font-family: var(--mono); }
|
.bar-chart-val { font-size: 10px; color: var(--text-3); font-family: var(--mono); }
|
||||||
.bar-chart-track { width: 100%; background: var(--accent-dim); border-radius: 4px; position: relative; height: 100px; }
|
.bar-chart-track { width: 100%; background: var(--accent-dim); border-radius: var(--radius-sm); position: relative; height: 100px; }
|
||||||
.bar-chart-fill { position: absolute; bottom: 0; left: 0; right: 0; background: var(--accent); border-radius: 4px; transition: height 0.3s ease; }
|
.bar-chart-fill { position: absolute; bottom: 0; left: 0; right: 0; background: var(--accent); border-radius: var(--radius-sm); transition: height 0.3s ease; }
|
||||||
.bar-chart-label { font-size: 10px; color: var(--text-3); }
|
.bar-chart-label { font-size: 10px; color: var(--text-3); }
|
||||||
|
|
||||||
/* ── Admin Settings Form (flat sections, prototype match) ── */
|
/* ── Admin Settings Form (flat sections, prototype match) ── */
|
||||||
@@ -250,11 +250,11 @@
|
|||||||
.admin-settings-form .toggle-label { margin-bottom: 8px; }
|
.admin-settings-form .toggle-label { margin-bottom: 8px; }
|
||||||
|
|
||||||
/* ── Toggle Switch ────────────────────────── */
|
/* ── Toggle Switch ────────────────────────── */
|
||||||
.toggle-label { display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px; color: var(--text-1); }
|
.toggle-label { display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px; color: var(--text); }
|
||||||
.toggle-label input[type="checkbox"] { display: none; }
|
.toggle-label input[type="checkbox"] { display: none; }
|
||||||
.toggle-track {
|
.toggle-track {
|
||||||
position: relative; width: 36px; height: 20px; flex-shrink: 0;
|
position: relative; width: 36px; height: 20px; flex-shrink: 0;
|
||||||
background: var(--text-3); border-radius: 10px; transition: background 0.2s;
|
background: var(--text-3); border-radius: var(--radius-lg); transition: background 0.2s;
|
||||||
}
|
}
|
||||||
.toggle-track::after {
|
.toggle-track::after {
|
||||||
content: ''; position: absolute; top: 2px; left: 2px;
|
content: ''; position: absolute; top: 2px; left: 2px;
|
||||||
@@ -288,25 +288,31 @@
|
|||||||
.admin-user-name { font-weight: 600; font-size: 13px; }
|
.admin-user-name { font-weight: 600; font-size: 13px; }
|
||||||
.admin-user-handle { font-size: 11px; color: var(--text-3); margin-top: 1px; }
|
.admin-user-handle { font-size: 11px; color: var(--text-3); margin-top: 1px; }
|
||||||
.admin-user-time { color: var(--text-3); font-size: 12px; font-family: var(--mono); }
|
.admin-user-time { color: var(--text-3); font-size: 12px; font-family: var(--mono); }
|
||||||
.admin-actions-cell { white-space: nowrap; text-align: right; }
|
.admin-actions-cell { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
|
||||||
|
|
||||||
/* ── Provider Cards ──────────────────────── */
|
/* ── Provider Cards ──────────────────────── */
|
||||||
.provider-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
.provider-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--sp-4); }
|
||||||
.provider-card {
|
.provider-card {
|
||||||
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px;
|
background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
padding: 16px; cursor: pointer; transition: border-color var(--transition), box-shadow var(--transition);
|
padding: var(--sp-4); cursor: pointer; transition: border-color var(--transition), box-shadow var(--transition);
|
||||||
}
|
}
|
||||||
.provider-card:hover { border-color: var(--accent); box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
.provider-card:hover { border-color: var(--accent); box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||||||
.provider-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
|
.provider-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--sp-3); }
|
||||||
.provider-card-name { font-weight: 600; font-size: 14px; }
|
.provider-card-name { font-weight: 600; font-size: 14px; }
|
||||||
.provider-card-meta { display: flex; gap: 6px; align-items: center; margin-top: 4px; flex-wrap: wrap; }
|
.provider-card-meta { display: flex; gap: 6px; align-items: center; margin-top: 4px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 1024px) {
|
||||||
.settings-nav { width: 160px; font-size: 12px; padding: 12px 8px; }
|
.settings-nav { width: 180px; }
|
||||||
.settings-content { padding: 16px; }
|
|
||||||
.admin-nav { width: 160px; }
|
.admin-nav { width: 160px; }
|
||||||
.admin-content-body { padding: 16px; }
|
.editor-tree { width: 180px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.settings-nav { width: 160px; font-size: 12px; padding: var(--sp-3) var(--sp-2); }
|
||||||
|
.settings-content { padding: var(--sp-4); }
|
||||||
|
.admin-nav { width: 160px; }
|
||||||
|
.admin-content-body { padding: var(--sp-4); }
|
||||||
.editor-tree { width: 180px; }
|
.editor-tree { width: 180px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +328,7 @@
|
|||||||
}
|
}
|
||||||
.sidebar-nav::-webkit-scrollbar { width: 4px; }
|
.sidebar-nav::-webkit-scrollbar { width: 4px; }
|
||||||
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
|
.sidebar-nav::-webkit-scrollbar-track { background: transparent; }
|
||||||
.sidebar-nav::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
.sidebar-nav::-webkit-scrollbar-thumb { background: var(--border); border-radius: var(--radius-sm); }
|
||||||
|
|
||||||
/* Section wrapper */
|
/* Section wrapper */
|
||||||
.sb-section { margin-bottom: 2px; }
|
.sb-section { margin-bottom: 2px; }
|
||||||
@@ -364,7 +370,7 @@
|
|||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transition: opacity 0.1s, color 0.1s;
|
transition: opacity 0.1s, color 0.1s;
|
||||||
@@ -394,7 +400,7 @@
|
|||||||
gap: 5px;
|
gap: 5px;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
@@ -446,7 +452,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
transition: opacity 0.1s;
|
transition: opacity 0.1s;
|
||||||
}
|
}
|
||||||
.sb-proj-header:hover .sb-proj-menu { opacity: 1; }
|
.sb-proj-header:hover .sb-proj-menu { opacity: 1; }
|
||||||
@@ -480,7 +486,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 7px;
|
gap: 7px;
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -494,7 +500,7 @@
|
|||||||
|
|
||||||
.sb-ch-menu {
|
.sb-ch-menu {
|
||||||
visibility: hidden; background: none; border: none; color: var(--text-3); cursor: pointer;
|
visibility: hidden; background: none; border: none; color: var(--text-3); cursor: pointer;
|
||||||
font-size: 14px; padding: 0 4px; line-height: 1; border-radius: 3px; margin-left: auto;
|
font-size: 14px; padding: 0 4px; line-height: 1; border-radius: var(--radius-sm); margin-left: auto;
|
||||||
}
|
}
|
||||||
.sb-ch-menu:hover { color: var(--text); background: var(--bg-hover); }
|
.sb-ch-menu:hover { color: var(--text); background: var(--bg-hover); }
|
||||||
.sb-channel-item:hover .sb-ch-menu { visibility: visible; }
|
.sb-channel-item:hover .sb-ch-menu { visibility: visible; }
|
||||||
@@ -509,14 +515,14 @@
|
|||||||
background: var(--text-3);
|
background: var(--text-3);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.sb-presence.online { background: var(--success-light, #22c55e); }
|
.sb-presence.online { background: var(--success-light); }
|
||||||
|
|
||||||
.sb-unread {
|
.sb-unread {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
min-width: 16px;
|
min-width: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
.debug-check { display: flex; align-items: center; gap: 4px; color: var(--text-3); cursor: pointer; }
|
.debug-check { display: flex; align-items: center; gap: 4px; color: var(--text-3); cursor: pointer; }
|
||||||
.debug-badge {
|
.debug-badge {
|
||||||
font-size: 10px; background: var(--bg-raised); border: 1px solid var(--border);
|
font-size: 10px; background: var(--bg-raised); border: 1px solid var(--border);
|
||||||
border-radius: 10px; padding: 1px 6px; color: var(--text-3); margin-left: auto;
|
border-radius: var(--radius-lg); padding: 1px 6px; color: var(--text-3); margin-left: auto;
|
||||||
}
|
}
|
||||||
.debug-content {
|
.debug-content {
|
||||||
flex: 1; overflow-y: auto; padding: 6px;
|
flex: 1; overflow-y: auto; padding: 6px;
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
.debug-pre {
|
.debug-pre {
|
||||||
white-space: pre-wrap; word-break: break-all;
|
white-space: pre-wrap; word-break: break-all;
|
||||||
max-height: 200px; overflow: auto; padding: 6px;
|
max-height: 200px; overflow: auto; padding: 6px;
|
||||||
background: rgba(0,0,0,0.2); border-radius: 4px; margin: 4px 0;
|
background: rgba(0,0,0,0.2); border-radius: var(--radius-sm); margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Console Tab ─────────────────────────── */
|
/* ── Console Tab ─────────────────────────── */
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
──────────────────────────────────────────── */
|
──────────────────────────────────────────── */
|
||||||
|
|
||||||
/* ── Layout ──────────────────────────────────── */
|
/* ── Layout ──────────────────────────────────── */
|
||||||
.login-shell { display: flex; flex-direction: column; height: 100vh; }
|
.login-shell { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
|
||||||
.login-main { display: flex; flex: 1; min-height: 0; }
|
.login-main { display: flex; flex: 1; min-height: 0; }
|
||||||
|
|
||||||
/* ── Hero Panel (left) ─────────────────────── */
|
/* ── Hero Panel (left) ─────────────────────── */
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
/* ── Auth Panel (right) ────────────────────── */
|
/* ── Auth Panel (right) ────────────────────── */
|
||||||
.login-auth-panel {
|
.login-auth-panel {
|
||||||
width: 440px; flex-shrink: 0; display: flex; align-items: center;
|
width: 440px; flex-shrink: 0; display: flex; align-items: center;
|
||||||
justify-content: center; padding: 2rem; background: var(--bg-surface);
|
justify-content: center; padding: var(--sp-8); background: var(--bg-surface);
|
||||||
border-left: 1px solid var(--border); overflow-y: auto;
|
border-left: 1px solid var(--border); overflow-y: auto;
|
||||||
}
|
}
|
||||||
.login-auth-inner { width: 100%; max-width: 340px; }
|
.login-auth-inner { width: 100%; max-width: 340px; }
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
.login-auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
.login-auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||||||
.login-auth-tab:hover { color: var(--text-2); }
|
.login-auth-tab:hover { color: var(--text-2); }
|
||||||
|
|
||||||
.login-form-field { margin-bottom: 16px; }
|
.login-form-field { margin-bottom: var(--sp-4); }
|
||||||
.login-form-field label {
|
.login-form-field label {
|
||||||
display: block; font-size: 13px; font-weight: 500;
|
display: block; font-size: 13px; font-weight: 500;
|
||||||
color: var(--text-2); margin-bottom: 6px;
|
color: var(--text-2); margin-bottom: 6px;
|
||||||
@@ -92,13 +92,20 @@
|
|||||||
.login-form-field input {
|
.login-form-field input {
|
||||||
width: 100%; box-sizing: border-box; background: var(--input-bg);
|
width: 100%; box-sizing: border-box; background: var(--input-bg);
|
||||||
border: 1px solid var(--border); color: var(--text);
|
border: 1px solid var(--border); color: var(--text);
|
||||||
padding: 10px 14px; border-radius: 10px; font-size: 14px; font-family: var(--font);
|
padding: var(--sp-3) var(--sp-3); border-radius: var(--radius-lg); font-size: 14px; font-family: var(--font);
|
||||||
outline: none; transition: border-color 0.15s, box-shadow 0.15s;
|
outline: none; transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
}
|
}
|
||||||
.login-form-field input:focus {
|
.login-form-field input:focus {
|
||||||
border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim);
|
border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-keep-login {
|
||||||
|
display: flex; align-items: center; gap: 6px; margin-top: 4px;
|
||||||
|
font-size: 0.82rem; color: var(--text-2); cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.login-keep-login input[type="checkbox"] { margin: 0; cursor: pointer; }
|
||||||
|
|
||||||
.login-auth-error {
|
.login-auth-error {
|
||||||
color: var(--danger); font-size: 0.78rem; margin: 0 0 0.5rem;
|
color: var(--danger); font-size: 0.78rem; margin: 0 0 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -106,7 +113,7 @@
|
|||||||
|
|
||||||
.login-btn {
|
.login-btn {
|
||||||
width: 100%; background: var(--accent); color: #fff; border: none;
|
width: 100%; background: var(--accent); color: #fff; border: none;
|
||||||
padding: 10px 18px; font-size: 14px; border-radius: 10px; cursor: pointer;
|
padding: var(--sp-3) var(--sp-5); font-size: 14px; border-radius: var(--radius-lg); cursor: pointer;
|
||||||
font-family: var(--font); font-weight: 600; letter-spacing: -0.01em;
|
font-family: var(--font); font-weight: 600; letter-spacing: -0.01em;
|
||||||
transition: all 0.18s; margin-top: 1.5rem;
|
transition: all 0.18s; margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -151,7 +158,7 @@
|
|||||||
.login-anim-a3 { animation-delay: .29s; }
|
.login-anim-a3 { animation-delay: .29s; }
|
||||||
|
|
||||||
/* ── Responsive ─────────────────────────────── */
|
/* ── Responsive ─────────────────────────────── */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 1024px) {
|
||||||
.login-hero { display: none; }
|
.login-hero { display: none; }
|
||||||
.login-auth-panel { width: 100%; border-left: none; }
|
.login-auth-panel { width: 100%; border-left: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
white-space: nowrap; position: relative;
|
white-space: nowrap; position: relative;
|
||||||
}
|
}
|
||||||
.sw-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
.sw-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
.sw-btn--sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
|
.sw-btn--sm { padding: var(--sp-1h) var(--sp-2h); font-size: 0.8rem; }
|
||||||
.sw-btn--md { padding: 0.45rem 0.9rem; font-size: 0.875rem; }
|
.sw-btn--md { padding: var(--sp-2) var(--sp-3); font-size: 0.875rem; }
|
||||||
.sw-btn--lg { padding: 0.6rem 1.2rem; font-size: 1rem; }
|
.sw-btn--lg { padding: var(--sp-2) var(--sp-4); font-size: 1rem; }
|
||||||
|
|
||||||
.sw-btn--primary { background: var(--accent); color: var(--text-on-color); }
|
.sw-btn--primary { background: var(--accent); color: var(--text-on-color); }
|
||||||
.sw-btn--primary:hover:not(:disabled) { background: var(--accent-hover); }
|
.sw-btn--primary:hover:not(:disabled) { background: var(--accent-hover); }
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
.sw-btn--secondary:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-light); }
|
.sw-btn--secondary:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-light); }
|
||||||
.sw-btn--danger { background: var(--danger); color: var(--text-on-color); }
|
.sw-btn--danger { background: var(--danger); color: var(--text-on-color); }
|
||||||
.sw-btn--danger:hover:not(:disabled) { opacity: 0.9; }
|
.sw-btn--danger:hover:not(:disabled) { opacity: 0.9; }
|
||||||
|
.sw-btn--success { background: var(--success); color: var(--text-on-color); }
|
||||||
|
.sw-btn--success:hover:not(:disabled) { opacity: 0.9; }
|
||||||
.sw-btn--ghost { background: transparent; color: var(--text-2); }
|
.sw-btn--ghost { background: transparent; color: var(--text-2); }
|
||||||
.sw-btn--ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
|
.sw-btn--ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
|
||||||
|
|
||||||
@@ -60,7 +62,7 @@
|
|||||||
|
|
||||||
/* ── FormField ─────────────────────────────── */
|
/* ── FormField ─────────────────────────────── */
|
||||||
|
|
||||||
.sw-field { display: flex; flex-direction: column; gap: 0.3rem; }
|
.sw-field { display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||||
.sw-field__label { font-size: 0.8rem; font-weight: 500; color: var(--text-2); }
|
.sw-field__label { font-size: 0.8rem; font-weight: 500; color: var(--text-2); }
|
||||||
.sw-field__required { color: var(--danger); margin-left: 2px; }
|
.sw-field__required { color: var(--danger); margin-left: 2px; }
|
||||||
.sw-field__error { font-size: 0.75rem; color: var(--danger); }
|
.sw-field__error { font-size: 0.75rem; color: var(--danger); }
|
||||||
@@ -70,7 +72,7 @@
|
|||||||
/* ── Shared input ──────────────────────────── */
|
/* ── Shared input ──────────────────────────── */
|
||||||
|
|
||||||
.sw-input {
|
.sw-input {
|
||||||
width: 100%; padding: 0.45rem 0.7rem; background: var(--input-bg);
|
width: 100%; padding: var(--sp-2) var(--sp-2h); background: var(--input-bg);
|
||||||
color: var(--text); border: 1px solid var(--border); border-radius: var(--radius);
|
color: var(--text); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
font-family: var(--font); font-size: 0.875rem; outline: none;
|
font-family: var(--font); font-size: 0.875rem; outline: none;
|
||||||
transition: border-color var(--transition);
|
transition: border-color var(--transition);
|
||||||
@@ -82,7 +84,7 @@
|
|||||||
|
|
||||||
.sw-tooltip-wrap { position: relative; display: inline-flex; }
|
.sw-tooltip-wrap { position: relative; display: inline-flex; }
|
||||||
.sw-tooltip {
|
.sw-tooltip {
|
||||||
position: absolute; z-index: 1000; padding: 0.3rem 0.6rem;
|
position: absolute; z-index: 1000; padding: var(--sp-1) var(--sp-2);
|
||||||
background: var(--bg-elevated); color: var(--text); border: 1px solid var(--border);
|
background: var(--bg-elevated); color: var(--text); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius); font-size: 0.75rem; white-space: nowrap;
|
border-radius: var(--radius); font-size: 0.75rem; white-space: nowrap;
|
||||||
pointer-events: none; animation: sw-fade-in 0.15s ease;
|
pointer-events: none; animation: sw-fade-in 0.15s ease;
|
||||||
@@ -98,7 +100,7 @@
|
|||||||
|
|
||||||
.sw-banner {
|
.sw-banner {
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
gap: 0.5rem; padding: 0.5rem 1rem; font-size: 0.85rem; font-weight: 500;
|
gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); font-size: 0.85rem; font-weight: 500;
|
||||||
width: 100%; flex-shrink: 0;
|
width: 100%; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.sw-banner--info { background: var(--accent-dim); color: var(--accent-light); }
|
.sw-banner--info { background: var(--accent-dim); color: var(--accent-light); }
|
||||||
@@ -114,13 +116,13 @@
|
|||||||
/* ── Toast ─────────────────────────────────── */
|
/* ── Toast ─────────────────────────────────── */
|
||||||
|
|
||||||
.sw-toast-container {
|
.sw-toast-container {
|
||||||
position: fixed; bottom: 1rem; right: 1rem; z-index: 10000;
|
position: fixed; bottom: var(--sp-4); right: var(--sp-4); z-index: 10000;
|
||||||
display: flex; flex-direction: column-reverse; gap: 0.5rem;
|
display: flex; flex-direction: column-reverse; gap: var(--sp-2);
|
||||||
max-width: 380px; pointer-events: none;
|
max-width: 380px; pointer-events: none;
|
||||||
}
|
}
|
||||||
.sw-toast {
|
.sw-toast {
|
||||||
display: flex; align-items: center; gap: 0.75rem;
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
padding: 0.75rem 1rem; border-radius: var(--radius-lg);
|
padding: var(--sp-3) var(--sp-4); border-radius: var(--radius-lg);
|
||||||
background: var(--bg-raised); border: 1px solid var(--border);
|
background: var(--bg-raised); border: 1px solid var(--border);
|
||||||
box-shadow: var(--shadow-lg); pointer-events: auto;
|
box-shadow: var(--shadow-lg); pointer-events: auto;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
@@ -156,7 +158,7 @@
|
|||||||
}
|
}
|
||||||
.sw-dialog__header {
|
.sw-dialog__header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 1rem 1.25rem 0.75rem; border-bottom: 1px solid var(--border);
|
padding: var(--sp-4) var(--sp-5) var(--sp-3); border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
.sw-dialog__title { font-size: 1rem; font-weight: 600; color: var(--text); margin: 0; }
|
.sw-dialog__title { font-size: 1rem; font-weight: 600; color: var(--text); margin: 0; }
|
||||||
.sw-dialog__close {
|
.sw-dialog__close {
|
||||||
@@ -164,10 +166,10 @@
|
|||||||
font-size: 1.3rem; padding: 0; line-height: 1;
|
font-size: 1.3rem; padding: 0; line-height: 1;
|
||||||
}
|
}
|
||||||
.sw-dialog__close:hover { color: var(--text); }
|
.sw-dialog__close:hover { color: var(--text); }
|
||||||
.sw-dialog__body { padding: 1rem 1.25rem; overflow-y: auto; color: var(--text); font-size: 0.9rem; line-height: 1.5; }
|
.sw-dialog__body { padding: var(--sp-4) var(--sp-5); overflow-y: auto; color: var(--text); font-size: 0.9rem; line-height: 1.5; }
|
||||||
.sw-dialog__actions {
|
.sw-dialog__actions {
|
||||||
display: flex; gap: 0.5rem; justify-content: flex-end;
|
display: flex; gap: var(--sp-2); justify-content: flex-end;
|
||||||
padding: 0.75rem 1.25rem; border-top: 1px solid var(--border);
|
padding: var(--sp-3) var(--sp-5); border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes sw-scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
@keyframes sw-scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||||
@@ -178,11 +180,11 @@
|
|||||||
z-index: 6000; min-width: 160px; max-width: 300px;
|
z-index: 6000; min-width: 160px; max-width: 300px;
|
||||||
background: var(--bg-surface); border: 1px solid var(--border);
|
background: var(--bg-surface); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius); box-shadow: var(--shadow-lg);
|
border-radius: var(--radius); box-shadow: var(--shadow-lg);
|
||||||
padding: 4px 0; outline: none; animation: sw-fade-in 0.1s ease;
|
padding: var(--sp-1) 0; outline: none; animation: sw-fade-in 0.1s ease;
|
||||||
}
|
}
|
||||||
.sw-menu__item {
|
.sw-menu__item {
|
||||||
display: flex; align-items: center; gap: 0.5rem;
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
padding: 0.4rem 0.75rem; cursor: pointer; font-size: 0.85rem;
|
padding: var(--sp-1h) var(--sp-3); cursor: pointer; font-size: 0.85rem;
|
||||||
color: var(--text); transition: background var(--transition);
|
color: var(--text); transition: background var(--transition);
|
||||||
}
|
}
|
||||||
.sw-menu__item:hover, .sw-menu__item--focused { background: var(--bg-hover); }
|
.sw-menu__item:hover, .sw-menu__item--focused { background: var(--bg-hover); }
|
||||||
@@ -191,11 +193,11 @@
|
|||||||
.sw-menu__icon { flex-shrink: 0; width: 1.1em; text-align: center; color: var(--text-2); }
|
.sw-menu__icon { flex-shrink: 0; width: 1.1em; text-align: center; color: var(--text-2); }
|
||||||
.sw-menu__label { flex: 1; }
|
.sw-menu__label { flex: 1; }
|
||||||
.sw-menu__arrow { font-size: 0.6em; color: var(--text-3); }
|
.sw-menu__arrow { font-size: 0.6em; color: var(--text-3); }
|
||||||
.sw-menu__divider { height: 1px; background: var(--border); margin: 4px 0; }
|
.sw-menu__divider { height: 1px; background: var(--border); margin: var(--sp-1) 0; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sw-menu { max-width: calc(100vw - 16px); }
|
.sw-menu { max-width: calc(100vw - 16px); }
|
||||||
.sw-menu__item { min-height: 44px; padding: 0.5rem 0.75rem; }
|
.sw-menu__item { min-height: 44px; padding: var(--sp-2h) var(--sp-3); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Drawer ────────────────────────────────── */
|
/* ── Drawer ────────────────────────────────── */
|
||||||
@@ -219,7 +221,7 @@
|
|||||||
.sw-drawer--left.sw-drawer--open { transform: translateX(0); }
|
.sw-drawer--left.sw-drawer--open { transform: translateX(0); }
|
||||||
.sw-drawer__header {
|
.sw-drawer__header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); flex-shrink: 0;
|
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.sw-drawer__title { font-size: 1rem; font-weight: 600; color: var(--text); margin: 0; }
|
.sw-drawer__title { font-size: 1rem; font-weight: 600; color: var(--text); margin: 0; }
|
||||||
.sw-drawer__close {
|
.sw-drawer__close {
|
||||||
@@ -227,7 +229,7 @@
|
|||||||
font-size: 1.3rem; padding: 0; line-height: 1;
|
font-size: 1.3rem; padding: 0; line-height: 1;
|
||||||
}
|
}
|
||||||
.sw-drawer__close:hover { color: var(--text); }
|
.sw-drawer__close:hover { color: var(--text); }
|
||||||
.sw-drawer__body { flex: 1; overflow-y: auto; padding: 1rem 1.25rem; }
|
.sw-drawer__body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5); }
|
||||||
|
|
||||||
/* ── Tabs ──────────────────────────────────── */
|
/* ── Tabs ──────────────────────────────────── */
|
||||||
|
|
||||||
@@ -241,7 +243,7 @@
|
|||||||
}
|
}
|
||||||
.sw-tabs__scroll::-webkit-scrollbar { display: none; }
|
.sw-tabs__scroll::-webkit-scrollbar { display: none; }
|
||||||
.sw-tabs__tab {
|
.sw-tabs__tab {
|
||||||
padding: 0.5rem 0.9rem; background: none; border: none;
|
padding: var(--sp-2h) var(--sp-3); background: none; border: none;
|
||||||
border-bottom: 2px solid transparent; color: var(--text-2);
|
border-bottom: 2px solid transparent; color: var(--text-2);
|
||||||
font-family: var(--font); font-size: 0.85rem; font-weight: 500;
|
font-family: var(--font); font-size: 0.85rem; font-weight: 500;
|
||||||
cursor: pointer; white-space: nowrap;
|
cursor: pointer; white-space: nowrap;
|
||||||
@@ -262,7 +264,7 @@
|
|||||||
.sw-dropdown { position: relative; display: inline-block; }
|
.sw-dropdown { position: relative; display: inline-block; }
|
||||||
.sw-dropdown__trigger {
|
.sw-dropdown__trigger {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
gap: 0.5rem; width: 100%; padding: 0.45rem 0.7rem;
|
gap: var(--sp-2); width: 100%; padding: var(--sp-2) var(--sp-3);
|
||||||
background: var(--input-bg); color: var(--text);
|
background: var(--input-bg); color: var(--text);
|
||||||
border: 1px solid var(--border); border-radius: var(--radius);
|
border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
font-family: var(--font); font-size: 0.875rem; cursor: pointer;
|
font-family: var(--font); font-size: 0.875rem; cursor: pointer;
|
||||||
@@ -272,19 +274,50 @@
|
|||||||
.sw-dropdown__value { flex: 1; text-align: left; }
|
.sw-dropdown__value { flex: 1; text-align: left; }
|
||||||
.sw-dropdown__chevron { color: var(--text-3); font-size: 0.7em; }
|
.sw-dropdown__chevron { color: var(--text-3); font-size: 0.7em; }
|
||||||
.sw-dropdown__list {
|
.sw-dropdown__list {
|
||||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
position: absolute; top: calc(100% + 4px); left: 0;
|
||||||
z-index: 6000; max-height: 240px; overflow-y: auto;
|
min-width: max-content;
|
||||||
|
z-index: 6000; max-height: 240px; overflow-y: auto; overflow-x: hidden;
|
||||||
background: var(--bg-surface); border: 1px solid var(--border);
|
background: var(--bg-surface); border: 1px solid var(--border);
|
||||||
border-radius: var(--radius); box-shadow: var(--shadow-lg);
|
border-radius: var(--radius); box-shadow: var(--shadow-lg);
|
||||||
animation: sw-fade-in 0.1s ease;
|
animation: sw-fade-in 0.1s ease;
|
||||||
}
|
}
|
||||||
.sw-dropdown__search { margin: 4px; width: calc(100% - 8px); }
|
.sw-dropdown__search { margin: var(--sp-1); width: calc(100% - var(--sp-2)); }
|
||||||
.sw-dropdown__option {
|
.sw-dropdown__option {
|
||||||
padding: 0.4rem 0.75rem; cursor: pointer; font-size: 0.85rem;
|
padding: var(--sp-1h) var(--sp-3); cursor: pointer; font-size: 0.85rem;
|
||||||
color: var(--text); transition: background var(--transition);
|
color: var(--text); transition: background var(--transition);
|
||||||
}
|
}
|
||||||
.sw-dropdown__option:hover, .sw-dropdown__option--focused { background: var(--bg-hover); }
|
.sw-dropdown__option:hover, .sw-dropdown__option--focused { background: var(--bg-hover); }
|
||||||
.sw-dropdown__option--selected { color: var(--accent); font-weight: 500; }
|
.sw-dropdown__option--selected { color: var(--accent); font-weight: 500; }
|
||||||
.sw-dropdown__option--disabled { opacity: 0.4; cursor: not-allowed; }
|
.sw-dropdown__option--disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
.sw-dropdown__option--disabled:hover { background: transparent; }
|
.sw-dropdown__option--disabled:hover { background: transparent; }
|
||||||
.sw-dropdown__empty { padding: 0.75rem; text-align: center; color: var(--text-3); font-size: 0.85rem; }
|
.sw-dropdown__empty { padding: var(--sp-3); text-align: center; color: var(--text-3); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* ── Focus Indicators ─────────────────────── */
|
||||||
|
|
||||||
|
.sw-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--accent); outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.sw-input:focus-visible {
|
||||||
|
outline: 2px solid var(--accent); outline-offset: -1px;
|
||||||
|
}
|
||||||
|
.sw-dropdown__trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--accent); outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.sw-menu__item:focus-visible {
|
||||||
|
outline: 2px solid var(--accent); outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.sw-tabs__tab:focus-visible {
|
||||||
|
outline: 2px solid var(--accent); outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile Touch Targets (44px minimum) ──── */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sw-banner__close { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
.sw-toast__close { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
.sw-dialog__close { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
.sw-drawer__close { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
.sw-tabs__arrow { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
|
||||||
|
.sw-tabs__tab { min-height: 44px; }
|
||||||
|
.sw-dropdown__option { min-height: 44px; display: flex; align-items: center; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,32 +3,29 @@
|
|||||||
fixed banners, announcement, surface, and footer.
|
fixed banners, announcement, surface, and footer.
|
||||||
──────────────────────────────────────────── */
|
──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* v0.6.10: .sw-shell is no longer a viewport-level container.
|
||||||
|
The single layout root is <body> in base.html.
|
||||||
|
.sw-shell fills its parent if any surface uses it. */
|
||||||
.sw-shell {
|
.sw-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
/* Safe-area insets for notched devices */
|
|
||||||
padding-top: env(safe-area-inset-top, 0);
|
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
|
||||||
padding-left: env(safe-area-inset-left, 0);
|
|
||||||
padding-right: env(safe-area-inset-right, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Banner (fixed top / bottom — identical component) ── */
|
/* ── Banner (fixed top / bottom — identical component) ── */
|
||||||
|
|
||||||
|
/* v0.6.10: banners are in-flow (template renders them), not fixed. */
|
||||||
.sw-shell__banner {
|
.sw-shell__banner {
|
||||||
position: fixed;
|
position: static;
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 900;
|
z-index: 900;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: var(--sp-2);
|
||||||
padding: 0.45rem 1rem;
|
padding: var(--sp-2) var(--sp-4);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -60,13 +57,12 @@
|
|||||||
|
|
||||||
/* ── Shell body — insets for fixed banners ─── */
|
/* ── Shell body — insets for fixed banners ─── */
|
||||||
|
|
||||||
|
/* v0.6.10: template banners are in-flow — no padding offset needed. */
|
||||||
.sw-shell__body {
|
.sw-shell__body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding-top: var(--banner-top-height, 0px);
|
|
||||||
padding-bottom: var(--banner-bottom-height, 0px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Announcement bar ─────────────────────── */
|
/* ── Announcement bar ─────────────────────── */
|
||||||
@@ -79,8 +75,8 @@
|
|||||||
.sw-shell__announcement-inner {
|
.sw-shell__announcement-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: var(--sp-3);
|
||||||
padding: 0.6rem 1.25rem;
|
padding: var(--sp-2) var(--sp-5);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +101,7 @@
|
|||||||
|
|
||||||
.sw-shell__footer {
|
.sw-shell__footer {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0.5rem 1rem;
|
padding: var(--sp-2) var(--sp-4);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
@@ -129,11 +125,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
padding: 0 12px;
|
padding: 0 var(--sp-3);
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: 10px;
|
gap: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-topbar__title {
|
.sw-topbar__title {
|
||||||
@@ -151,13 +147,13 @@
|
|||||||
.sw-topbar__slot {
|
.sw-topbar__slot {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-topbar__right {
|
.sw-topbar__right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── User menu trigger ───────────────────── */
|
/* ── User menu trigger ───────────────────── */
|
||||||
@@ -170,7 +166,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transition: box-shadow var(--transition, 0.15s ease);
|
transition: box-shadow var(--transition);
|
||||||
}
|
}
|
||||||
.sw-user-menu__trigger:hover {
|
.sw-user-menu__trigger:hover {
|
||||||
box-shadow: 0 0 0 2px var(--accent-dim);
|
box-shadow: 0 0 0 2px var(--accent-dim);
|
||||||
@@ -195,28 +191,28 @@
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
color: var(--text-3, #555);
|
color: var(--text-3);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.15s, background 0.15s;
|
transition: color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-notification-bell__trigger:hover {
|
.sw-notification-bell__trigger:hover {
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
background: var(--bg, #0e0e10);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-notification-bell__badge {
|
.sw-notification-bell__badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
right: 2px;
|
right: 2px;
|
||||||
background: var(--danger, #ef4444);
|
background: var(--danger);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
min-width: 14px;
|
min-width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border-radius: 7px;
|
border-radius: var(--radius);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -231,9 +227,9 @@
|
|||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
width: 320px;
|
width: 320px;
|
||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
background: var(--bg-secondary, #151517);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -245,25 +241,25 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 14px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
border-bottom: 1px solid var(--border);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-notification-bell__mark-all {
|
.sw-notification-bell__mark-all {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--accent, #b38a4e);
|
color: var(--accent);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-notification-bell__mark-all:hover {
|
.sw-notification-bell__mark-all:hover {
|
||||||
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
|
background: var(--accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-notification-bell__list {
|
.sw-notification-bell__list {
|
||||||
@@ -272,15 +268,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sw-notification-bell__empty {
|
.sw-notification-bell__empty {
|
||||||
padding: 24px 14px;
|
padding: var(--sp-6) var(--sp-3);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-3, #555);
|
color: var(--text-3);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-notification-bell__item {
|
.sw-notification-bell__item {
|
||||||
padding: 10px 14px;
|
padding: var(--sp-3) var(--sp-3);
|
||||||
border-bottom: 1px solid var(--border, #2a2a2e);
|
border-bottom: 1px solid var(--border);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
@@ -290,11 +286,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sw-notification-bell__item:hover {
|
.sw-notification-bell__item:hover {
|
||||||
background: var(--bg, #0e0e10);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-notification-bell__item--unread {
|
.sw-notification-bell__item--unread {
|
||||||
background: var(--accent-dim, rgba(179, 138, 78, 0.05));
|
background: var(--accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-notification-bell__item--unread::before {
|
.sw-notification-bell__item--unread::before {
|
||||||
@@ -303,27 +299,27 @@
|
|||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent, #b38a4e);
|
background: var(--accent);
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-notification-bell__item-text {
|
.sw-notification-bell__item-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-notification-bell__item-body {
|
.sw-notification-bell__item-body {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-3, #555);
|
color: var(--text-3);
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sw-notification-bell__item-time {
|
.sw-notification-bell__item-time {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-3, #555);
|
color: var(--text-3);
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -336,7 +332,7 @@
|
|||||||
|
|
||||||
.sw-notification-bell__item-action {
|
.sw-notification-bell__item-action {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--accent, #4a9eff);
|
color: var(--accent);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/* v0.25.0: Tool grants section in persona form */
|
/* v0.25.0: Tool grants section in persona form */
|
||||||
.tool-grants-list {
|
.tool-grants-list {
|
||||||
border: 1px solid var(--border, #2a2a2e);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: var(--bg, #0e0e10);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-grants-category {
|
.tool-grants-category {
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
color: var(--text-3, #777);
|
color: var(--text-3);
|
||||||
padding: 6px 0 2px;
|
padding: 6px 0 2px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
@@ -27,9 +27,9 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 3px 4px;
|
padding: 3px 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-grants-item:hover {
|
.tool-grants-item:hover {
|
||||||
|
|||||||
@@ -19,12 +19,12 @@
|
|||||||
.user-menu-wrap .user-btn {
|
.user-menu-wrap .user-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: var(--sp-2);
|
||||||
padding: 6px 8px;
|
padding: var(--sp-2) var(--sp-2);
|
||||||
border-radius: var(--radius, 6px);
|
border-radius: var(--radius);
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text, #eee);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-menu-wrap .user-btn:hover {
|
.user-menu-wrap .user-btn:hover {
|
||||||
background: var(--bg-hover, rgba(255,255,255,0.06));
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Avatar ────────────────────────────────── */
|
/* ── Avatar ────────────────────────────────── */
|
||||||
@@ -41,13 +41,13 @@
|
|||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--accent-dim, rgba(179, 138, 78, 0.2));
|
background: var(--accent-dim);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--accent, #b38a4e);
|
color: var(--accent);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
/* Sidebar collapsed: hide label, center avatar */
|
/* Sidebar collapsed: hide label, center avatar */
|
||||||
.sidebar.collapsed .user-menu-wrap .user-btn {
|
.sidebar.collapsed .user-menu-wrap .user-btn {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 6px 0;
|
padding: var(--sp-2) 0;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
/* User menu button: ensure 44px touch target */
|
/* User menu button: ensure 44px touch target */
|
||||||
.user-menu-wrap .user-btn {
|
.user-menu-wrap .user-btn {
|
||||||
padding: 8px;
|
padding: var(--sp-2);
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,39 @@
|
|||||||
Theme variables, reset, base styles
|
Theme variables, reset, base styles
|
||||||
──────────────────────────────────────────── */
|
──────────────────────────────────────────── */
|
||||||
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap');
|
/* ── Self-hosted fonts (zero external dependencies) ── */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'DM Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/dm-sans-latin.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'DM Sans';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 300 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/dm-sans-latin-ext.woff2') format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/jetbrains-mono-latin.woff2') format('woff2');
|
||||||
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400 500;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('../fonts/jetbrains-mono-latin-ext.woff2') format('woff2');
|
||||||
|
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
/* ==========================================
|
/* ==========================================
|
||||||
Armature
|
Armature
|
||||||
@@ -15,6 +47,7 @@
|
|||||||
--bg-surface: #18181b;
|
--bg-surface: #18181b;
|
||||||
--bg-raised: #222227;
|
--bg-raised: #222227;
|
||||||
--bg-hover: #2a2a30;
|
--bg-hover: #2a2a30;
|
||||||
|
--bg-active: #2f2f38;
|
||||||
--bg-elevated: #42424e;
|
--bg-elevated: #42424e;
|
||||||
--border: #2e2e35;
|
--border: #2e2e35;
|
||||||
--border-elevated: #555560;
|
--border-elevated: #555560;
|
||||||
@@ -22,11 +55,11 @@
|
|||||||
--text: #e8e8ed;
|
--text: #e8e8ed;
|
||||||
--text-2: #9898a8;
|
--text-2: #9898a8;
|
||||||
--text-3: #6b6b7b;
|
--text-3: #6b6b7b;
|
||||||
--accent: #6c9fff;
|
--accent: #6493ed;
|
||||||
--accent-hover: #84b0ff;
|
--accent-hover: #7ca8f5;
|
||||||
--accent-dim: rgba(108,159,255,0.12);
|
--accent-dim: rgba(100,147,237,0.12);
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
--success: #22c55e;
|
--success: #1dab51;
|
||||||
--warning: #eab308;
|
--warning: #eab308;
|
||||||
--purple: #a78bfa;
|
--purple: #a78bfa;
|
||||||
--purple-dim: rgba(167,139,250,0.10);
|
--purple-dim: rgba(167,139,250,0.10);
|
||||||
@@ -52,12 +85,13 @@
|
|||||||
--overlay: rgba(0,0,0,0.55);
|
--overlay: rgba(0,0,0,0.55);
|
||||||
--glass: rgba(255,255,255,0.03);
|
--glass: rgba(255,255,255,0.03);
|
||||||
--input-bg: #1a1a1f;
|
--input-bg: #1a1a1f;
|
||||||
--user-bubble: rgba(108,159,255,0.08);
|
--user-bubble: rgba(100,147,237,0.08);
|
||||||
--danger-bg: rgba(239,68,68,0.12);
|
--danger-bg: rgba(239,68,68,0.12);
|
||||||
--bg-code: #1a1a22;
|
--bg-code: #1a1a22;
|
||||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
|
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
|
||||||
|
|
||||||
/* ── Layout ──────────────────────────────── */
|
/* ── Layout ──────────────────────────────── */
|
||||||
|
--radius-sm: 4px;
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--radius-lg: 12px;
|
--radius-lg: 12px;
|
||||||
--sidebar-w: 260px;
|
--sidebar-w: 260px;
|
||||||
@@ -66,6 +100,25 @@
|
|||||||
--mono: 'JetBrains Mono', 'Söhne Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
--mono: 'JetBrains Mono', 'Söhne Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
--transition: 180ms ease;
|
--transition: 180ms ease;
|
||||||
|
|
||||||
|
/* -- Spacing (4px grid + half-steps) ------- */
|
||||||
|
--sp-1: 0.25rem; /* 4px */
|
||||||
|
--sp-1h: 0.375rem; /* 6px */
|
||||||
|
--sp-2: 0.5rem; /* 8px */
|
||||||
|
--sp-2h: 0.625rem; /* 10px */
|
||||||
|
--sp-3: 0.75rem; /* 12px */
|
||||||
|
--sp-4: 1rem; /* 16px */
|
||||||
|
--sp-5: 1.25rem; /* 20px */
|
||||||
|
--sp-6: 1.5rem; /* 24px */
|
||||||
|
--sp-8: 2rem; /* 32px */
|
||||||
|
--sp-10: 2.5rem; /* 40px */
|
||||||
|
--sp-12: 3rem; /* 48px */
|
||||||
|
|
||||||
|
/* -- Breakpoints --------------------------
|
||||||
|
Mobile: max-width: 768px
|
||||||
|
Tablet: max-width: 1024px
|
||||||
|
Desktop: default (no query)
|
||||||
|
----------------------------------------- */
|
||||||
|
|
||||||
/* Banner system — set by JS from /api/v1/admin/settings/banner */
|
/* Banner system — set by JS from /api/v1/admin/settings/banner */
|
||||||
--banner-top-height: 0px;
|
--banner-top-height: 0px;
|
||||||
--banner-bottom-height: 0px;
|
--banner-bottom-height: 0px;
|
||||||
@@ -81,13 +134,14 @@
|
|||||||
--bg-surface: #ffffff;
|
--bg-surface: #ffffff;
|
||||||
--bg-raised: #eff0f3;
|
--bg-raised: #eff0f3;
|
||||||
--bg-hover: #e5e6eb;
|
--bg-hover: #e5e6eb;
|
||||||
|
--bg-active: #dddee5;
|
||||||
--bg-elevated: #ffffff;
|
--bg-elevated: #ffffff;
|
||||||
--border: #d5d6dc;
|
--border: #d5d6dc;
|
||||||
--border-elevated: #c0c0c8;
|
--border-elevated: #c0c0c8;
|
||||||
--border-light: #c2c3cb;
|
--border-light: #c2c3cb;
|
||||||
--text: #1a1a2e;
|
--text: #1a1a2e;
|
||||||
--text-2: #555770;
|
--text-2: #555770;
|
||||||
--text-3: #8b8da3;
|
--text-3: #787a92;
|
||||||
--accent: #4a7cdb;
|
--accent: #4a7cdb;
|
||||||
--accent-hover: #3968c4;
|
--accent-hover: #3968c4;
|
||||||
--accent-dim: rgba(74,124,219,0.09);
|
--accent-dim: rgba(74,124,219,0.09);
|
||||||
@@ -102,8 +156,8 @@
|
|||||||
|
|
||||||
--accent-light: #2563eb;
|
--accent-light: #2563eb;
|
||||||
--danger-light: #dc2626;
|
--danger-light: #dc2626;
|
||||||
--success-light: #16a34a;
|
--success-light: #128a3e;
|
||||||
--warning-light: #ca8a04;
|
--warning-light: #a97200;
|
||||||
|
|
||||||
--danger-dim: rgba(220,38,38,0.09);
|
--danger-dim: rgba(220,38,38,0.09);
|
||||||
--success-dim: rgba(22,163,74,0.09);
|
--success-dim: rgba(22,163,74,0.09);
|
||||||
@@ -130,7 +184,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text); over
|
|||||||
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: var(--radius-sm); }
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
|
::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
|
||||||
a { color: var(--accent); text-decoration: none; }
|
a { color: var(--accent); text-decoration: none; }
|
||||||
a:hover { text-decoration: underline; }
|
a:hover { text-decoration: underline; }
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
@@ -27,15 +27,15 @@
|
|||||||
}
|
}
|
||||||
.wf-stage-name { flex: 1; font-size: 13px; font-weight: 500; }
|
.wf-stage-name { flex: 1; font-size: 13px; font-weight: 500; }
|
||||||
.wf-stage-row .badge { font-size: 10px; }
|
.wf-stage-row .badge { font-size: 10px; }
|
||||||
.wf-stage-row .btn-small { opacity: 0; transition: opacity 0.15s; }
|
.wf-stage-row .sw-btn--sm { opacity: 0; transition: opacity 0.15s; }
|
||||||
.wf-stage-row:hover .btn-small { opacity: 1; }
|
.wf-stage-row:hover .sw-btn--sm { opacity: 1; }
|
||||||
|
|
||||||
/* ── Queue sidebar ───────────────────── */
|
/* ── Queue sidebar ───────────────────── */
|
||||||
|
|
||||||
.sb-queue-badge {
|
.sb-queue-badge {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 1px 7px;
|
padding: 1px 7px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
@@ -55,16 +55,16 @@
|
|||||||
.sb-queue-item.active { background: var(--accent-dim); color: var(--accent); }
|
.sb-queue-item.active { background: var(--accent-dim); color: var(--accent); }
|
||||||
.sb-queue-icon { font-size: 14px; }
|
.sb-queue-icon { font-size: 14px; }
|
||||||
.sb-queue-label { flex: 1; }
|
.sb-queue-label { flex: 1; }
|
||||||
.sb-queue-item .btn-small {
|
.sb-queue-item .sw-btn--sm {
|
||||||
opacity: 0; padding: 2px 6px; font-size: 11px;
|
opacity: 0; padding: 2px 6px; font-size: 11px;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
.sb-queue-item:hover .btn-small { opacity: 1; }
|
.sb-queue-item:hover .sw-btn--sm { opacity: 1; }
|
||||||
|
|
||||||
/* Collapsed sidebar — show icon only */
|
/* Collapsed sidebar — show icon only */
|
||||||
.sidebar.collapsed .sb-queue-item { justify-content: center; padding: 5px 0; margin: 0 6px; gap: 0; }
|
.sidebar.collapsed .sb-queue-item { justify-content: center; padding: 5px 0; margin: 0 6px; gap: 0; }
|
||||||
.sidebar.collapsed .sb-queue-label { display: none; }
|
.sidebar.collapsed .sb-queue-label { display: none; }
|
||||||
.sidebar.collapsed .sb-queue-item .btn-small { display: none; }
|
.sidebar.collapsed .sb-queue-item .sw-btn--sm { display: none; }
|
||||||
.sidebar.collapsed .sb-queue-badge { display: none !important; }
|
.sidebar.collapsed .sb-queue-badge { display: none !important; }
|
||||||
|
|
||||||
/* Unpin button */
|
/* Unpin button */
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
/* Browse workflows button */
|
/* Browse workflows button */
|
||||||
.sb-queue-browse {
|
.sb-queue-browse {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 6px 12px; cursor: pointer; border-radius: 6px;
|
padding: 6px 12px; cursor: pointer; border-radius: var(--radius);
|
||||||
font-size: 12px; color: var(--text-3); transition: background 0.15s;
|
font-size: 12px; color: var(--text-3); transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.sb-queue-browse:hover { background: var(--bg-raised); color: var(--text-2); }
|
.sb-queue-browse:hover { background: var(--bg-raised); color: var(--text-2); }
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
.wf-browse-dialog { max-height: 300px; overflow-y: auto; }
|
.wf-browse-dialog { max-height: 300px; overflow-y: auto; }
|
||||||
.wf-browse-row {
|
.wf-browse-row {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 8px 12px; cursor: pointer; border-radius: 6px;
|
padding: 8px 12px; cursor: pointer; border-radius: var(--radius);
|
||||||
font-size: 13px; transition: background 0.15s;
|
font-size: 13px; transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.wf-browse-row:hover { background: var(--bg-raised); }
|
.wf-browse-row:hover { background: var(--bg-raised); }
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
|
|
||||||
/* ── Admin badges ────────────────────── */
|
/* ── Admin badges ────────────────────── */
|
||||||
|
|
||||||
.badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: var(--bg-raised); color: var(--text-2); }
|
.badge { font-size: 11px; padding: 2px 8px; border-radius: var(--radius-lg); background: var(--bg-raised); color: var(--text-2); }
|
||||||
.badge-ok { background: rgba(46, 160, 67, 0.15); color: #2ea043; }
|
.badge-ok { background: rgba(46, 160, 67, 0.15); color: #2ea043; }
|
||||||
.badge-warn { background: rgba(210, 153, 34, 0.15); color: #d29922; }
|
.badge-warn { background: rgba(210, 153, 34, 0.15); color: #d29922; }
|
||||||
|
|
||||||
@@ -143,14 +143,14 @@
|
|||||||
.wf-bar-status {
|
.wf-bar-status {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
padding: 1px 8px;
|
padding: 1px 8px;
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
.wf-bar-status.active { background: var(--accent-dim); color: var(--accent); }
|
.wf-bar-status.active { background: var(--accent-dim); color: var(--accent); }
|
||||||
.wf-bar-status.completed { background: var(--success-dim); color: var(--success-light); }
|
.wf-bar-status.completed { background: var(--success-dim); color: var(--success-light); }
|
||||||
.wf-bar-status.stale { background: var(--warning-dim); color: var(--warning-light); }
|
.wf-bar-status.stale { background: var(--warning-dim); color: var(--warning-light); }
|
||||||
.wf-bar-spacer { flex: 1; }
|
.wf-bar-spacer { flex: 1; }
|
||||||
.wf-bar-actions { display: flex; gap: 6px; }
|
.wf-bar-actions { display: flex; gap: 6px; }
|
||||||
.wf-bar-actions .btn-small { font-size: 11px; padding: 3px 10px; }
|
.wf-bar-actions .sw-btn--sm { font-size: 11px; padding: 3px 10px; }
|
||||||
|
|
||||||
/* DnD stage reorder feedback */
|
/* DnD stage reorder feedback */
|
||||||
.wf-stage-row.dragging { opacity: 0.4; }
|
.wf-stage-row.dragging { opacity: 0.4; }
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -177,7 +177,7 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
src/fonts/dm-sans-latin-ext.woff2
Normal file
BIN
src/fonts/dm-sans-latin-ext.woff2
Normal file
Binary file not shown.
BIN
src/fonts/dm-sans-latin.woff2
Normal file
BIN
src/fonts/dm-sans-latin.woff2
Normal file
Binary file not shown.
BIN
src/fonts/jetbrains-mono-latin-ext.woff2
Normal file
BIN
src/fonts/jetbrains-mono-latin-ext.woff2
Normal file
Binary file not shown.
BIN
src/fonts/jetbrains-mono-latin.woff2
Normal file
BIN
src/fonts/jetbrains-mono-latin.woff2
Normal file
Binary file not shown.
@@ -102,13 +102,13 @@ function DebugModal({ engine }) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer debug-modal-footer">
|
<div class="modal-footer debug-modal-footer">
|
||||||
<div class="debug-footer-left">
|
<div class="debug-footer-left">
|
||||||
<button class="btn-danger btn-small" onClick=${() => { setActiveTab('console'); engine.runDiagnostics(); }}>Diagnostics</button>
|
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => { setActiveTab('console'); engine.runDiagnostics(); }}>Diagnostics</button>
|
||||||
<button class="btn-danger btn-small" onClick=${onPurge}>Purge Cache</button>
|
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${onPurge}>Purge Cache</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="debug-footer-right">
|
<div class="debug-footer-right">
|
||||||
<button class="btn-secondary btn-small" onClick=${onClear}>Clear</button>
|
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${onClear}>Clear</button>
|
||||||
<button class="btn-secondary btn-small" onClick=${() => engine.copyLog()}>Copy</button>
|
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => engine.copyLog()}>Copy</button>
|
||||||
<button class="btn-secondary btn-small" onClick=${() => engine.downloadLog()}>Export</button>
|
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => engine.downloadLog()}>Export</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,21 +16,28 @@ export function Menu({ items = [], anchor, open, direction = 'down-right', onSel
|
|||||||
|
|
||||||
const selectableItems = items.filter(i => !i.divider && !i.disabled);
|
const selectableItems = items.filter(i => !i.divider && !i.disabled);
|
||||||
|
|
||||||
// Position relative to anchor
|
// Position relative to anchor, accounting for CSS zoom on #surfaceInner.
|
||||||
// When the surface uses CSS transform: scale(), getBoundingClientRect()
|
// getBoundingClientRect() returns viewport coords, but position:fixed inside
|
||||||
// returns scaled coordinates but position:fixed uses viewport coordinates.
|
// a zoomed ancestor needs coords divided by the zoom factor.
|
||||||
// Dividing by the scale factor converts back to true viewport pixels.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !anchor) return;
|
if (!open || !anchor) return;
|
||||||
const scale = window.sw?.shell?.getScale?.() || 1;
|
const rawRect = typeof anchor.getBoundingClientRect === 'function'
|
||||||
const raw = typeof anchor.getBoundingClientRect === 'function'
|
|
||||||
? anchor.getBoundingClientRect()
|
? anchor.getBoundingClientRect()
|
||||||
: anchor;
|
: anchor;
|
||||||
const rect = scale !== 1
|
|
||||||
? { top: raw.top / scale, bottom: raw.bottom / scale, left: raw.left / scale, right: raw.right / scale }
|
// Detect CSS zoom on surface container
|
||||||
: raw;
|
const zoomEl = document.getElementById('surfaceInner');
|
||||||
const vw = window.innerWidth / scale;
|
const zoom = zoomEl ? (parseFloat(zoomEl.style.zoom) || 1) : 1;
|
||||||
const vh = window.innerHeight / scale;
|
|
||||||
|
// Convert viewport coords to zoom-space
|
||||||
|
const rect = {
|
||||||
|
top: rawRect.top / zoom,
|
||||||
|
bottom: rawRect.bottom / zoom,
|
||||||
|
left: rawRect.left / zoom,
|
||||||
|
right: rawRect.right / zoom,
|
||||||
|
};
|
||||||
|
const vw = window.innerWidth / zoom;
|
||||||
|
const vh = window.innerHeight / zoom;
|
||||||
|
|
||||||
let top, left;
|
let top, left;
|
||||||
const [vDir, hDir] = direction.split('-');
|
const [vDir, hDir] = direction.split('-');
|
||||||
|
|||||||
@@ -288,6 +288,7 @@ export function createDomains(restClient) {
|
|||||||
// ── Users ──────────────────────────────
|
// ── Users ──────────────────────────────
|
||||||
users: {
|
users: {
|
||||||
search: (q) => rc.get('/api/v1/users/search' + _qs({ q })),
|
search: (q) => rc.get('/api/v1/users/search' + _qs({ q })),
|
||||||
|
resolve: (ids) => rc.get('/api/v1/users/resolve' + _qs({ ids: ids.join(',') })),
|
||||||
},
|
},
|
||||||
|
|
||||||
presence: {
|
presence: {
|
||||||
|
|||||||
@@ -36,15 +36,18 @@ export function createAuth() {
|
|||||||
|
|
||||||
// ── Token persistence ───────────────────────
|
// ── Token persistence ───────────────────────
|
||||||
|
|
||||||
|
let _cookieMaxAge = 604800; // default 7 days, updated from server response
|
||||||
|
|
||||||
function _saveTokens() {
|
function _saveTokens() {
|
||||||
localStorage.setItem(_storageKey, JSON.stringify({
|
localStorage.setItem(_storageKey, JSON.stringify({
|
||||||
accessToken: _accessToken,
|
accessToken: _accessToken,
|
||||||
refreshToken: _refreshToken,
|
refreshToken: _refreshToken,
|
||||||
user: _user,
|
user: _user,
|
||||||
|
cookieMaxAge: _cookieMaxAge,
|
||||||
}));
|
}));
|
||||||
// Cookie sync for Go template page auth
|
// Cookie sync for Go template page auth
|
||||||
if (_accessToken) {
|
if (_accessToken) {
|
||||||
document.cookie = `arm_token=${_accessToken}; path=/; max-age=900; SameSite=Strict`;
|
document.cookie = `arm_token=${_accessToken}; path=/; max-age=${_cookieMaxAge}; SameSite=Strict`;
|
||||||
} else {
|
} else {
|
||||||
document.cookie = 'arm_token=; path=/; max-age=0';
|
document.cookie = 'arm_token=; path=/; max-age=0';
|
||||||
}
|
}
|
||||||
@@ -57,6 +60,7 @@ export function createAuth() {
|
|||||||
_accessToken = saved.accessToken || null;
|
_accessToken = saved.accessToken || null;
|
||||||
_refreshToken = saved.refreshToken || null;
|
_refreshToken = saved.refreshToken || null;
|
||||||
_user = saved.user || null;
|
_user = saved.user || null;
|
||||||
|
if (saved.cookieMaxAge) _cookieMaxAge = saved.cookieMaxAge;
|
||||||
}
|
}
|
||||||
} catch (_) { /* corrupt storage */ }
|
} catch (_) { /* corrupt storage */ }
|
||||||
}
|
}
|
||||||
@@ -81,6 +85,7 @@ export function createAuth() {
|
|||||||
_accessToken = data.access_token;
|
_accessToken = data.access_token;
|
||||||
_refreshToken = data.refresh_token;
|
_refreshToken = data.refresh_token;
|
||||||
_user = data.user;
|
_user = data.user;
|
||||||
|
if (data.refresh_expires_in) _cookieMaxAge = data.refresh_expires_in;
|
||||||
_saveTokens();
|
_saveTokens();
|
||||||
_scheduleRefresh(data.expires_in || 900);
|
_scheduleRefresh(data.expires_in || 900);
|
||||||
}
|
}
|
||||||
@@ -149,8 +154,8 @@ export function createAuth() {
|
|||||||
/**
|
/**
|
||||||
* Login with credentials. Stores tokens, fetches permissions, emits event.
|
* Login with credentials. Stores tokens, fetches permissions, emits event.
|
||||||
*/
|
*/
|
||||||
async login(login, password) {
|
async login(login, password, keepLogin = false) {
|
||||||
const data = await _restClient.post('/api/v1/auth/login', { login, password });
|
const data = await _restClient.post('/api/v1/auth/login', { login, password, keep_login: keepLogin });
|
||||||
_setAuth(data);
|
_setAuth(data);
|
||||||
await _fetchPermissions();
|
await _fetchPermissions();
|
||||||
_emit('auth.login', { user: auth.user }, { localOnly: true });
|
_emit('auth.login', { user: auth.user }, { localOnly: true });
|
||||||
@@ -245,9 +250,30 @@ export function createAuth() {
|
|||||||
|
|
||||||
if (_accessToken) {
|
if (_accessToken) {
|
||||||
_emit('auth.boot', { user: auth.user }, { localOnly: true });
|
_emit('auth.boot', { user: auth.user }, { localOnly: true });
|
||||||
|
auth._startActivityTracking();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Activity Ping (idle-timeout support) ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start activity tracking. Sends POST /api/v1/auth/activity
|
||||||
|
* on user interaction, debounced to max once per minute.
|
||||||
|
*/
|
||||||
|
_startActivityTracking() {
|
||||||
|
let lastPing = 0;
|
||||||
|
const INTERVAL = 60_000; // 1 minute
|
||||||
|
const ping = () => {
|
||||||
|
if (!_accessToken) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastPing < INTERVAL) return;
|
||||||
|
lastPing = now;
|
||||||
|
_restClient.post('/api/v1/auth/activity', {}).catch(() => {});
|
||||||
|
};
|
||||||
|
document.addEventListener('click', ping, { passive: true });
|
||||||
|
document.addEventListener('keydown', ping, { passive: true });
|
||||||
|
},
|
||||||
|
|
||||||
// ── Internal (called by rest-client / index.js) ──
|
// ── Internal (called by rest-client / index.js) ──
|
||||||
|
|
||||||
_getToken() { return _accessToken; },
|
_getToken() { return _accessToken; },
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { createActions } from './actions.js';
|
|||||||
import { createRealtime } from './realtime.js';
|
import { createRealtime } from './realtime.js';
|
||||||
import { createRenderers } from './renderers.js';
|
import { createRenderers } from './renderers.js';
|
||||||
import { createMarkdown } from './markdown.js';
|
import { createMarkdown } from './markdown.js';
|
||||||
|
import { createUsers } from './users.js';
|
||||||
import { confirm } from '../primitives/confirm.js';
|
import { confirm } from '../primitives/confirm.js';
|
||||||
import { prompt } from '../primitives/prompt.js';
|
import { prompt } from '../primitives/prompt.js';
|
||||||
|
|
||||||
@@ -117,21 +118,17 @@ export async function boot() {
|
|||||||
// Markdown — unified renderer (marked + DOMPurify + sw.renderers pipeline)
|
// Markdown — unified renderer (marked + DOMPurify + sw.renderers pipeline)
|
||||||
sw.markdown = markdown;
|
sw.markdown = markdown;
|
||||||
|
|
||||||
|
// Users — identity resolution with local caching
|
||||||
|
sw.users = createUsers(restClient);
|
||||||
|
|
||||||
// Shell helpers — imperative confirm/prompt backed by primitives
|
// Shell helpers — imperative confirm/prompt backed by primitives
|
||||||
sw.confirm = confirm;
|
sw.confirm = confirm;
|
||||||
sw.prompt = prompt;
|
sw.prompt = prompt;
|
||||||
|
|
||||||
// Shell — layout utilities (decouples surface code from shell DOM)
|
// Shell — layout utilities (decouples surface code from shell DOM)
|
||||||
const _shell = {
|
const _shell = {
|
||||||
/** CSS transform scale on #surfaceInner (appearance zoom). */
|
/** @deprecated v0.6.10 — CSS zoom handles reflow; no scale correction needed. */
|
||||||
getScale() {
|
getScale() { return 1; },
|
||||||
const el = document.getElementById('surfaceInner');
|
|
||||||
if (!el) return 1;
|
|
||||||
const t = getComputedStyle(el).transform;
|
|
||||||
if (!t || t === 'none') return 1;
|
|
||||||
const m = t.match(/matrix\(([^,]+)/);
|
|
||||||
return m ? parseFloat(m[1]) || 1 : 1;
|
|
||||||
},
|
|
||||||
/** Topbar — set after dynamic import below */
|
/** Topbar — set after dynamic import below */
|
||||||
Topbar: null,
|
Topbar: null,
|
||||||
};
|
};
|
||||||
@@ -196,7 +193,24 @@ export async function boot() {
|
|||||||
events.connect(BASE + '/ws');
|
events.connect(BASE + '/ws');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. Signal ready
|
// 10. Mount imperative hosts (Toast, DialogStack) if not already present.
|
||||||
|
// Core surfaces mount these via AppShell; extension surfaces need SDK to do it.
|
||||||
|
try {
|
||||||
|
const hostId = 'sw-imperative-hosts';
|
||||||
|
if (!document.getElementById(hostId)) {
|
||||||
|
const mount = document.createElement('div');
|
||||||
|
mount.id = hostId;
|
||||||
|
document.body.appendChild(mount);
|
||||||
|
const { ToastContainer } = await import('../primitives/toast.js');
|
||||||
|
const { DialogStack } = await import('../shell/dialog-stack.js');
|
||||||
|
const { render } = preact;
|
||||||
|
render(html`<${ToastContainer} /><${DialogStack} />`, mount);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[sw] Imperative host mount failed:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Signal ready
|
||||||
events.emit('sdk.ready', {}, { localOnly: true });
|
events.emit('sdk.ready', {}, { localOnly: true });
|
||||||
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
|
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
|
||||||
console.log(`[sw] SDK v${sw._sdk} ready`);
|
console.log(`[sw] SDK v${sw._sdk} ready`);
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export function createMarkdown(renderers) {
|
|||||||
renderer(token) {
|
renderer(token) {
|
||||||
const target = _escapeAttr(token.target);
|
const target = _escapeAttr(token.target);
|
||||||
const display = _escapeHtml(token.display);
|
const display = _escapeHtml(token.display);
|
||||||
return `<a class="wikilink" data-wikilink="${target}" href="javascript:void(0)">${display}</a>`;
|
return `<a class="ext-notes-wikilink" data-wikilink="${target}" href="javascript:void(0)">${display}</a>`;
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
|
|
||||||
|
|||||||
104
src/js/sw/sdk/users.js
Normal file
104
src/js/sw/sdk/users.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// ==========================================
|
||||||
|
// Armature — SDK Users Module
|
||||||
|
// ==========================================
|
||||||
|
// Resolves user identity by ID with local caching.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// const user = await sw.users.resolve(id);
|
||||||
|
// const name = sw.users.displayName(user);
|
||||||
|
// const map = await sw.users.resolveMany([id1, id2]);
|
||||||
|
//
|
||||||
|
// Cache: 60-second TTL per entry. Batch-fetches missing
|
||||||
|
// IDs in a single GET /api/v1/users/resolve?ids=... call.
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const CACHE_TTL = 60_000; // 60 seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} restClient — SDK rest client (rc.get, rc.post, etc.)
|
||||||
|
* @returns {object} sw.users namespace
|
||||||
|
*/
|
||||||
|
export function createUsers(restClient) {
|
||||||
|
/** @type {Map<string, {data: object, fetchedAt: number}>} */
|
||||||
|
const _cache = new Map();
|
||||||
|
|
||||||
|
function _isFresh(entry) {
|
||||||
|
return entry && (Date.now() - entry.fetchedAt) < CACHE_TTL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve multiple user IDs to identity records.
|
||||||
|
* Deduplicates, checks cache, fetches remainder in one API call.
|
||||||
|
*
|
||||||
|
* @param {string[]} ids
|
||||||
|
* @returns {Promise<Map<string, {id, username, display_name, avatar_url}>>}
|
||||||
|
*/
|
||||||
|
async function resolveMany(ids) {
|
||||||
|
const unique = [...new Set(ids)].filter(Boolean);
|
||||||
|
const result = new Map();
|
||||||
|
const toFetch = [];
|
||||||
|
|
||||||
|
for (const id of unique) {
|
||||||
|
const cached = _cache.get(id);
|
||||||
|
if (_isFresh(cached)) {
|
||||||
|
result.set(id, cached.data);
|
||||||
|
} else {
|
||||||
|
toFetch.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toFetch.length > 0) {
|
||||||
|
try {
|
||||||
|
const resp = await restClient.get(
|
||||||
|
'/api/v1/users/resolve?ids=' + encodeURIComponent(toFetch.join(','))
|
||||||
|
);
|
||||||
|
// REST client does NOT auto-unwrap {data: Object} (only arrays).
|
||||||
|
const data = (resp && resp.data) || resp || {};
|
||||||
|
for (const id of toFetch) {
|
||||||
|
const user = data[id] || { id, username: '', display_name: '', avatar_url: '' };
|
||||||
|
_cache.set(id, { data: user, fetchedAt: Date.now() });
|
||||||
|
result.set(id, user);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[sw.users] resolve failed:', err.message);
|
||||||
|
// Return empty placeholders for unfetched IDs
|
||||||
|
for (const id of toFetch) {
|
||||||
|
const placeholder = { id, username: '', display_name: '', avatar_url: '' };
|
||||||
|
result.set(id, placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a single user ID.
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {Promise<{id, username, display_name, avatar_url}>}
|
||||||
|
*/
|
||||||
|
async function resolve(id) {
|
||||||
|
const m = await resolveMany([id]);
|
||||||
|
return m.get(id) || { id, username: '', display_name: '', avatar_url: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a user object for display using the canonical fallback chain.
|
||||||
|
* Pure/synchronous — no network call.
|
||||||
|
*
|
||||||
|
* @param {object|null} user — object with display_name and/or username
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function displayName(user) {
|
||||||
|
if (!user) return 'Unknown';
|
||||||
|
return user.display_name || user.username || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush the cache (useful after profile updates). */
|
||||||
|
function clearCache() {
|
||||||
|
_cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.freeze({ resolve, resolveMany, displayName, clearCache });
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
/**
|
/** @deprecated v0.6.10 — No surface imports AppShell. Layout root is <body> in base.html.
|
||||||
|
* Kept for reference; will be removed in a future version.
|
||||||
|
*
|
||||||
* AppShell — Layer 1 layout scaffold
|
* AppShell — Layer 1 layout scaffold
|
||||||
*
|
*
|
||||||
* ┌─────────────────────────────────┐ ← fixed banner (top, optional)
|
* ┌─────────────────────────────────┐ ← fixed banner (top, optional)
|
||||||
@@ -22,20 +24,10 @@
|
|||||||
const { html } = window;
|
const { html } = window;
|
||||||
const { useState, useEffect, useRef, useReducer } = hooks;
|
const { useState, useEffect, useRef, useReducer } = hooks;
|
||||||
|
|
||||||
|
/** @deprecated v0.6.10 — Banner height is measured by template scripts in base.html. */
|
||||||
function ShellBanner({ position, text, variant = 'info' }) {
|
function ShellBanner({ position, text, variant = 'info' }) {
|
||||||
const ref = useRef(null);
|
|
||||||
const prop = position === 'top' ? '--banner-top-height' : '--banner-bottom-height';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (ref.current) {
|
|
||||||
const h = ref.current.offsetHeight + 'px';
|
|
||||||
document.documentElement.style.setProperty(prop, h);
|
|
||||||
}
|
|
||||||
return () => document.documentElement.style.setProperty(prop, '0px');
|
|
||||||
}, [text, prop]);
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="sw-shell__banner sw-shell__banner--${position} sw-shell__banner--${variant}" ref=${ref}>
|
<div class="sw-shell__banner sw-shell__banner--${position} sw-shell__banner--${variant}">
|
||||||
<span class="sw-shell__banner-text">${text}</span>
|
<span class="sw-shell__banner-text">${text}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
/**
|
/** @deprecated v0.6.10 — No surface imports App. Layout root is <body> in base.html.
|
||||||
|
* Kept for reference; will be removed in a future version.
|
||||||
|
*
|
||||||
* App — root component composing the full shell tree
|
* App — root component composing the full shell tree
|
||||||
*
|
*
|
||||||
* ┌───────────────────────────────┐
|
* ┌───────────────────────────────┐
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
/**
|
/** @deprecated v0.6.10 — No surface imports SurfaceViewport. Layout root is <body> in base.html.
|
||||||
|
*
|
||||||
* SurfaceViewport — container where the active surface renders
|
* SurfaceViewport — container where the active surface renders
|
||||||
*
|
*
|
||||||
* Deliberately thin. Error boundaries and
|
* Deliberately thin. Error boundaries and
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function _currentSurface() {
|
|||||||
|
|
||||||
export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [extSurfaces, setExtSurfaces] = useState([]);
|
const [allSurfaces, setAllSurfaces] = useState([]);
|
||||||
const btnRef = useRef(null);
|
const btnRef = useRef(null);
|
||||||
|
|
||||||
const sw = window.sw;
|
const sw = window.sw;
|
||||||
@@ -35,17 +35,19 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
|||||||
const authenticated = sw?.auth?.isAuthenticated;
|
const authenticated = sw?.auth?.isAuthenticated;
|
||||||
|
|
||||||
// Fetch installed surfaces once on mount.
|
// Fetch installed surfaces once on mount.
|
||||||
// Filter out core surfaces that have dedicated menu entries below.
|
const CORE_IDS = new Set(['admin', 'settings', 'team-admin', 'workflow', 'workflow-landing', 'docs', 'welcome']);
|
||||||
const CORE_IDS = new Set(['admin', 'settings', 'team-admin', 'workflow', 'workflow-landing', 'docs']);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sw?.api?.surfaces?.list) return;
|
if (!sw?.api?.surfaces?.list) return;
|
||||||
sw.api.surfaces.list().then(data => {
|
sw.api.surfaces.list().then(data => {
|
||||||
const raw = Array.isArray(data) ? data : data?.data || [];
|
const raw = Array.isArray(data) ? data : data?.data || [];
|
||||||
setExtSurfaces(raw.filter(s => !CORE_IDS.has(s.id)));
|
setAllSurfaces(raw);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, [authenticated]);
|
}, [authenticated]);
|
||||||
|
|
||||||
|
const extSurfaces = allSurfaces.filter(s => !CORE_IDS.has(s.id));
|
||||||
|
const enabledIds = new Set(allSurfaces.map(s => s.id));
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
const current = _currentSurface();
|
const current = _currentSurface();
|
||||||
const list = [];
|
const list = [];
|
||||||
@@ -68,13 +70,13 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Standard items ─────────────────────
|
// ── Standard items ─────────────────────
|
||||||
// Docs — skip if current surface IS docs
|
// Docs — only if enabled and not current
|
||||||
if (current !== 'docs') {
|
if (current !== 'docs' && enabledIds.has('docs')) {
|
||||||
list.push({ label: 'Docs', action: 'docs', icon: '\ud83d\udcd6' });
|
list.push({ label: 'Docs', action: 'docs', icon: '\ud83d\udcd6' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings — skip if current surface IS settings
|
// Settings — only if enabled and not current
|
||||||
if (current !== 'settings') {
|
if (current !== 'settings' && enabledIds.has('settings')) {
|
||||||
list.push({ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' });
|
list.push({ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +87,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
|||||||
|
|
||||||
// Team Admin — requires admin role on at least one team
|
// Team Admin — requires admin role on at least one team
|
||||||
const hasTeamAdmin = sw?.auth?.teams?.some(t => t.my_role === 'admin');
|
const hasTeamAdmin = sw?.auth?.teams?.some(t => t.my_role === 'admin');
|
||||||
if (authenticated && hasTeamAdmin && current !== 'team-admin') {
|
if (authenticated && hasTeamAdmin && current !== 'team-admin' && enabledIds.has('team-admin')) {
|
||||||
list.push({ label: 'Team Admin', action: 'team-admin', icon: '\ud83d\udc65' });
|
list.push({ label: 'Team Admin', action: 'team-admin', icon: '\ud83d\udc65' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +100,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
|
|||||||
list.push({ label: 'Sign Out', action: 'sign-out' });
|
list.push({ label: 'Sign Out', action: 'sign-out' });
|
||||||
|
|
||||||
return list;
|
return list;
|
||||||
}, [user, authenticated, extraItems, extSurfaces]);
|
}, [user, authenticated, extraItems, extSurfaces, enabledIds]);
|
||||||
|
|
||||||
function handleSelect(action) {
|
function handleSelect(action) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|||||||
@@ -77,9 +77,9 @@ export default function AuditSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-top:12px;display:flex;align-items:center;gap:8px;">
|
<div style="margin-top:12px;display:flex;align-items:center;gap:8px;">
|
||||||
<button class="btn-small" disabled=${page <= 1} onClick=${() => setPage(p => p - 1)}>\u2190 Prev</button>
|
<button class="sw-btn sw-btn--secondary sw-btn--sm" disabled=${page <= 1} onClick=${() => setPage(p => p - 1)}>\u2190 Prev</button>
|
||||||
<span class="text-muted" style="font-size:12px;">Page ${page} of ${totalPages} (${total} total)</span>
|
<span class="text-muted" style="font-size:12px;">Page ${page} of ${totalPages} (${total} total)</span>
|
||||||
<button class="btn-small" disabled=${page >= totalPages} onClick=${() => setPage(p => p + 1)}>Next \u2192</button>
|
<button class="sw-btn sw-btn--secondary sw-btn--sm" disabled=${page >= totalPages} onClick=${() => setPage(p => p + 1)}>Next \u2192</button>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,13 +118,13 @@ export default function BackupSection() {
|
|||||||
return html`
|
return html`
|
||||||
<div>
|
<div>
|
||||||
<div style="display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;">
|
<div style="display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;">
|
||||||
<button class="btn btn-primary" onclick=${() => createBackup(false)} disabled=${creating || restoring}>
|
<button class="sw-btn sw-btn--primary sw-btn--md" onclick=${() => createBackup(false)} disabled=${creating || restoring}>
|
||||||
${creating ? 'Creating\u2026' : 'Download Backup'}
|
${creating ? 'Creating\u2026' : 'Download Backup'}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn" onclick=${() => createBackup(true)} disabled=${creating || restoring}>
|
<button class="btn" onclick=${() => createBackup(true)} disabled=${creating || restoring}>
|
||||||
${creating ? 'Creating\u2026' : 'Save to Server'}
|
${creating ? 'Creating\u2026' : 'Save to Server'}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger" onclick=${restoreBackup} disabled=${creating || restoring}>
|
<button class="sw-btn sw-btn--danger sw-btn--md" onclick=${restoreBackup} disabled=${creating || restoring}>
|
||||||
${restoring ? 'Restoring\u2026' : 'Restore from File'}
|
${restoring ? 'Restoring\u2026' : 'Restore from File'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,8 +149,8 @@ export default function BackupSection() {
|
|||||||
<td>${formatBytes(b.size)}</td>
|
<td>${formatBytes(b.size)}</td>
|
||||||
<td>${formatDate(b.created_at)}</td>
|
<td>${formatDate(b.created_at)}</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-sm" onclick=${() => downloadBackup(b.name)}>Download</button>
|
<button class="sw-btn sw-btn--secondary sw-btn--sm" onclick=${() => downloadBackup(b.name)}>Download</button>
|
||||||
<button class="btn btn-sm btn-danger" onclick=${() => deleteBackup(b.name)} style="margin-left:4px;">Delete</button>
|
<button class="sw-btn sw-btn--danger sw-btn--sm" onclick=${() => deleteBackup(b.name)} style="margin-left:4px;">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`)}
|
`)}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user