10 Commits

Author SHA1 Message Date
1236220302 Feat v0.6.18 ci bundled packages (#53)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m45s
CI/CD / test-sqlite (push) Successful in 2m58s
CI/CD / build-and-deploy (push) Successful in 1m37s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 16:49:01 +00:00
e7d1b53ebf Feat v0.6.17 bugfixes (#52)
Some checks failed
CI/CD / detect-changes (push) Successful in 16s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Failing after 2m54s
CI/CD / test-sqlite (push) Failing after 3m7s
CI/CD / build-and-deploy (push) Has been skipped
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 16:36:43 +00:00
ff19a1b4d3 Feat v0.6.16 usability survey gate (#51)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / build-and-deploy (push) Successful in 49s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 14:52:14 +00:00
d9802df2af Feat v0.6.15 user display audit (#50)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 3m10s
CI/CD / build-and-deploy (push) Successful in 1m31s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 14:19:48 +00:00
c9b9e68c18 Feat v0.6.14 visual polish (#49)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m42s
CI/CD / test-sqlite (push) Successful in 3m1s
CI/CD / build-and-deploy (push) Successful in 1m8s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 13:25:04 +00:00
3af62a9cc5 Feat v0.6.13 responsive spacing (#48)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m45s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 1m33s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 12:26:28 +00:00
221ae94f4f Feat v0.6.12 css isolation (#47)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Successful in 2m44s
CI/CD / test-sqlite (push) Successful in 2m47s
CI/CD / build-and-deploy (push) Successful in 1m46s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 11:58:39 +00:00
786bc92768 Feat v0.6.11 css dedup (#46)
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m44s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / build-and-deploy (push) Successful in 1m17s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 11:18:28 +00:00
ca3f845c34 Feat v0.6.10 viewport foundation (#45)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-sqlite (push) Successful in 3m5s
CI/CD / test-go-pg (push) Successful in 3m14s
CI/CD / build-and-deploy (push) Successful in 1m44s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 10:42:44 +00:00
617d81e7d4 Feat v0.6.9 cookie fix roadmap (#44)
Some checks failed
CI/CD / detect-changes (push) Successful in 19s
CI/CD / test-frontend (push) Successful in 27s
CI/CD / test-go-pg (push) Failing after 2m46s
CI/CD / test-sqlite (push) Successful in 3m24s
CI/CD / build-and-deploy (push) Has been skipped
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-04-01 09:41:59 +00:00
121 changed files with 5387 additions and 2332 deletions

View File

@@ -413,6 +413,7 @@ jobs:
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
echo "CPU_REQUEST=50m" >> "$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"
elif [[ "${{ gitea.ref }}" == refs/tags/v* ]]; then
VERSION="${{ gitea.ref_name }}"
@@ -429,6 +430,7 @@ jobs:
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
echo "CPU_REQUEST=100m" >> "$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 "env_label=production (${VERSION})" >> "$GITHUB_OUTPUT"
else
@@ -444,6 +446,7 @@ jobs:
echo "MEMORY_LIMIT=512Mi" >> "$GITHUB_OUTPUT"
echo "CPU_REQUEST=50m" >> "$GITHUB_OUTPUT"
echo "CPU_LIMIT=250m" >> "$GITHUB_OUTPUT"
echo "BUNDLED_PACKAGES=notes,chat,chat-core" >> "$GITHUB_OUTPUT"
echo "env_label=test (main)" >> "$GITHUB_OUTPUT"
fi
@@ -670,6 +673,7 @@ jobs:
STORAGE_CLASS: ${{ vars.STORAGE_CLASS }}
STORAGE_SIZE: ${{ vars.STORAGE_SIZE || '10Gi' }}
STORAGE_BACKEND: ${{ vars.STORAGE_BACKEND || 'pvc' }}
BUNDLED_PACKAGES: ${{ steps.setup.outputs.BUNDLED_PACKAGES }}
run: |
# Render PVC first (must exist before backend references it)
if [[ -n "${STORAGE_CLASS}" ]]; then

View File

@@ -2,6 +2,381 @@
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 5m60m) and `session.refresh_token_ttl` (default 7d, clamp
1h90d) 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.9v0.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
End-to-end mutual TLS without a reverse proxy. Targets systemd+podman

151
ROADMAP-UI.md Normal file
View 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 (7681024px). 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.

View File

@@ -1,6 +1,6 @@
# Armature — Roadmap
## Current: v0.6.7Native mTLS
## Current: v0.6.14Visual Polish
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
storage, realtime, and ops are kernel primitives. Everything else is an extension.

View File

@@ -1 +1 @@
0.6.7
0.6.18

View File

@@ -33,7 +33,7 @@ services:
EXT_ALLOW_PRIVATE_IPS: ${EXT_ALLOW_PRIVATE_IPS:-true}
LOG_FORMAT: ${LOG_FORMAT:-text}
LOG_LEVEL: ${LOG_LEVEL:-info}
BUNDLED_PACKAGES: ${BUNDLED_PACKAGES:-}
BUNDLED_PACKAGES: ${BUNDLED_PACKAGES:-*}
# Dev seed users — ignored if ENVIRONMENT=production
SEED_USERS: ${SEED_USERS:-alice:password123:user,bob:password456:user,charlie:password789:user}
volumes:

View File

@@ -3,14 +3,14 @@
## Docker Single-Instance
```bash
docker pull ghcr.io/armature/armature:latest
docker pull gobha/armature:latest
docker run -p 8080:80 \
-e ARMATURE_ADMIN_USERNAME=admin \
-e ARMATURE_ADMIN_PASSWORD=changeme \
-e JWT_SECRET="$(openssl rand -hex 32)" \
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
-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.
@@ -29,7 +29,7 @@ services:
- pg_data:/var/lib/postgresql/data
armature:
image: ghcr.io/armature/armature:latest
image: gobha/armature:latest
ports:
- "8080:80"
environment:

View File

@@ -3,11 +3,11 @@
## Quick Start
```bash
docker pull ghcr.io/armature/armature:latest
docker pull gobha/armature:latest
docker run -p 8080:80 \
-e ARMATURE_ADMIN_USERNAME=admin \
-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.
@@ -66,12 +66,12 @@ Set `BUNDLED_PACKAGES` to control which packages are installed:
# Install ALL packages (everything in the image)
docker run -p 8080:80 \
-e BUNDLED_PACKAGES="*" \
ghcr.io/armature/armature:latest
gobha/armature:latest
# Install specific packages only
docker run -p 8080:80 \
-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.
@@ -83,7 +83,7 @@ Set `SKIP_BUNDLED_PACKAGES=true` to prevent bundled packages from being installe
```bash
docker run -p 8080:80 \
-e SKIP_BUNDLED_PACKAGES=true \
ghcr.io/armature/armature:latest
gobha/armature:latest
```
### Custom Bundle Directory
@@ -94,7 +94,7 @@ Override the default bundled packages location with `BUNDLED_PACKAGES_DIR`:
docker run -p 8080:80 \
-e BUNDLED_PACKAGES_DIR=/custom/packages \
-v /host/packages:/custom/packages \
ghcr.io/armature/armature:latest
gobha/armature:latest
```
## 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.
```bash
docker pull ghcr.io/armature/builder:latest
docker pull gobha/armature-builder:latest
```
### 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:
```dockerfile
FROM ghcr.io/armature/builder:latest AS builder
FROM gobha/armature-builder:latest AS builder
WORKDIR /app
COPY server/ .
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
```bash
git clone https://github.com/armature/armature.git
git clone https://github.com/gobha/armature.git
cd armature
# Add/modify packages
@@ -189,13 +189,13 @@ docker run -p 8080:80 \
-e DATABASE_URL="postgres://user:pass@host:5432/armature?sslmode=require" \
-e JWT_SECRET="$(openssl rand -hex 32)" \
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
ghcr.io/armature/armature:latest
gobha/armature:latest
# SQLite (evaluation only)
docker run -p 8080:80 \
-e DB_DRIVER=sqlite \
-v armature-data:/data \
ghcr.io/armature/armature:latest
gobha/armature:latest
```
### Storage

196
docs/EXTENSION-CSS.md Normal file
View 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
View 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

View File

@@ -91,6 +91,9 @@ def create(title, type="group", participants=None, creator_id="", creator_displa
cid = conv["id"]
# 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:
db.insert("participants", {
"conversation_id": cid,
@@ -110,7 +113,7 @@ def create(title, type="group", participants=None, creator_id="", creator_displa
"conversation_id": cid,
"participant_id": pid,
"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")),
"joined_at": "",
})
@@ -213,7 +216,7 @@ def add_participant(conversation_id, participant_id, participant_type="user", di
"conversation_id": cid,
"participant_id": pid,
"participant_type": _str(participant_type),
"display_name": _str(display_name),
"display_name": _str(display_name), # DEPRECATED v0.6.15 — snapshot only
"role": _str(role),
"joined_at": "",
})

View File

@@ -9,29 +9,29 @@
/* ── Layout ─────────────────────────────── */
.chat-app {
.ext-chat-app {
display: flex;
flex-direction: column;
height: 100vh;
height: 100%;
overflow: hidden;
background: var(--bg);
color: var(--text);
}
.chat-loading {
.ext-chat-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
height: 100%;
}
.chat-body {
.ext-chat-body {
display: flex;
flex: 1;
min-height: 0;
}
.chat-main {
.ext-chat-main {
display: flex;
flex-direction: column;
flex: 1;
@@ -40,16 +40,16 @@
/* ── Topbar extras ──────────────────────── */
.chat-topbar__thread-title {
.ext-chat-topbar__thread-title {
font-weight: 600;
font-size: 14px;
margin-right: 8px;
margin-right: var(--sp-2);
color: var(--text-2);
}
/* ── Sidebar ────────────────────────────── */
.chat-sidebar {
.ext-chat-sidebar {
width: 280px;
min-width: 280px;
border-right: 1px solid var(--border);
@@ -58,76 +58,76 @@
background: var(--bg-secondary);
}
.chat-sidebar__header {
.ext-chat-sidebar__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
}
.chat-sidebar__title {
.ext-chat-sidebar__title {
font-weight: 600;
font-size: 14px;
}
.chat-sidebar__list {
.ext-chat-sidebar__list {
flex: 1;
overflow-y: auto;
}
.chat-sidebar__empty {
padding: 24px 16px;
.ext-chat-sidebar__empty {
padding: var(--sp-6) var(--sp-4);
text-align: center;
color: var(--text-3);
font-size: 13px;
}
.chat-sidebar__item {
padding: 10px 16px;
.ext-chat-sidebar__item {
padding: var(--sp-3) var(--sp-4);
cursor: pointer;
border-bottom: 1px solid var(--border-light);
transition: background 0.1s;
}
.chat-sidebar__item:hover {
.ext-chat-sidebar__item:hover {
background: var(--bg-hover);
}
.chat-sidebar__item--active {
.ext-chat-sidebar__item--active {
background: var(--accent-dim);
}
.chat-sidebar__item-top {
.ext-chat-sidebar__item-top {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 2px;
}
.chat-sidebar__item-title {
.ext-chat-sidebar__item-title {
font-weight: 600;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
margin-right: 8px;
margin-right: var(--sp-2);
}
.chat-sidebar__item-time {
.ext-chat-sidebar__item-time {
font-size: 11px;
color: var(--text-3);
white-space: nowrap;
}
.chat-sidebar__item-bottom {
.ext-chat-sidebar__item-bottom {
display: flex;
align-items: center;
gap: 6px;
gap: var(--sp-2);
}
.chat-sidebar__item-preview {
.ext-chat-sidebar__item-preview {
font-size: 12px;
color: var(--text-2);
white-space: nowrap;
@@ -136,7 +136,7 @@
flex: 1;
}
.chat-sidebar__badge {
.ext-chat-sidebar__badge {
background: var(--accent);
color: var(--text-on-color);
font-size: 11px;
@@ -153,17 +153,17 @@
/* ── Sidebar Search ────────────────────── */
.chat-sidebar__search {
.ext-chat-sidebar__search {
position: relative;
padding: 8px 16px;
padding: var(--sp-2) var(--sp-4);
border-bottom: 1px solid var(--border-light);
}
.chat-sidebar__search-input {
.ext-chat-sidebar__search-input {
width: 100%;
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 28px 6px 10px;
border-radius: var(--radius);
padding: var(--sp-2) var(--sp-6) var(--sp-2) var(--sp-3);
font-size: 13px;
font-family: inherit;
background: var(--input-bg);
@@ -171,12 +171,12 @@
box-sizing: border-box;
}
.chat-sidebar__search-input:focus {
.ext-chat-sidebar__search-input:focus {
outline: none;
border-color: var(--accent);
}
.chat-sidebar__search-clear {
.ext-chat-sidebar__search-clear {
position: absolute;
right: 22px;
top: 50%;
@@ -186,21 +186,21 @@
color: var(--text-3);
cursor: pointer;
font-size: 16px;
padding: 0 4px;
padding: 0 var(--sp-1);
line-height: 1;
}
.chat-sidebar__search-clear:hover {
.ext-chat-sidebar__search-clear:hover {
color: var(--text);
}
.chat-sidebar__search-results {
.ext-chat-sidebar__search-results {
flex: 1;
overflow-y: auto;
}
.chat-sidebar__search-section {
padding: 8px 16px 4px;
.ext-chat-sidebar__search-section {
padding: var(--sp-2) var(--sp-4) var(--sp-1);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
@@ -208,13 +208,13 @@
color: var(--text-3);
}
.chat-sidebar__search-loading {
.ext-chat-sidebar__search-loading {
display: flex;
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;
white-space: normal;
display: -webkit-box;
@@ -225,58 +225,58 @@
/* ── Message Thread ─────────────────────── */
.chat-thread {
.ext-chat-thread {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.chat-thread--empty {
.ext-chat-thread--empty {
align-items: center;
justify-content: center;
color: var(--text-3);
}
.chat-thread__messages {
.ext-chat-thread__messages {
flex: 1;
overflow-y: auto;
padding: 16px;
padding: var(--sp-4);
display: flex;
flex-direction: column;
gap: 4px;
gap: var(--sp-1);
}
.chat-thread__loading {
.ext-chat-thread__loading {
display: flex;
justify-content: center;
padding: 24px;
padding: var(--sp-6);
}
.chat-thread__loading-more {
.ext-chat-thread__loading-more {
display: flex;
justify-content: center;
padding: 8px;
padding: var(--sp-2);
}
.chat-thread__load-more {
.ext-chat-thread__load-more {
align-self: center;
background: none;
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 12px;
border-radius: var(--radius-sm);
padding: var(--sp-1) var(--sp-3);
font-size: 12px;
color: var(--text-2);
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);
}
.chat-thread__typing {
padding: 4px 16px 8px;
.ext-chat-thread__typing {
padding: var(--sp-1) var(--sp-4) var(--sp-2);
font-size: 12px;
color: var(--text-3);
font-style: italic;
@@ -284,52 +284,52 @@
/* ── Message Bubble ─────────────────────── */
.chat-msg {
.ext-chat-msg {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 4px 0;
gap: var(--sp-2);
padding: var(--sp-1) 0;
position: relative;
}
.chat-msg--own {
.ext-chat-msg--own {
flex-direction: row-reverse;
}
.chat-msg--system {
.ext-chat-msg--system {
justify-content: center;
padding: 2px 0;
}
.chat-msg--system span {
.ext-chat-msg--system span {
font-size: 12px;
color: var(--text-3);
font-style: italic;
}
.chat-msg--deleted {
.ext-chat-msg--deleted {
justify-content: center;
padding: 2px 0;
}
.chat-msg--deleted em {
.ext-chat-msg--deleted em {
font-size: 12px;
color: var(--text-3);
}
.chat-msg__body {
.ext-chat-msg__body {
max-width: 65%;
background: var(--bg-raised);
border-radius: 12px;
padding: 8px 12px;
border-radius: var(--radius-lg);
padding: var(--sp-2) var(--sp-3);
}
.chat-msg--own .chat-msg__body {
.ext-chat-msg--own .ext-chat-msg__body {
background: var(--accent);
color: var(--text-on-color);
}
.chat-msg__name {
.ext-chat-msg__name {
font-size: 11px;
font-weight: 600;
color: var(--text-2);
@@ -337,42 +337,42 @@
margin-bottom: 2px;
}
.chat-msg__content {
.ext-chat-msg__content {
font-size: 14px;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
.chat-msg__meta {
.ext-chat-msg__meta {
display: flex;
gap: 6px;
gap: var(--sp-2);
align-items: center;
margin-top: 2px;
}
.chat-msg__time {
.ext-chat-msg__time {
font-size: 10px;
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);
}
.chat-msg__edited {
.ext-chat-msg__edited {
font-size: 10px;
color: var(--text-3);
font-style: italic;
}
.chat-msg--own .chat-msg__edited {
.ext-chat-msg--own .ext-chat-msg__edited {
color: rgba(255, 255, 255, 0.7);
}
/* ── Message Actions ────────────────────── */
.chat-msg__actions {
.ext-chat-msg__actions {
display: flex;
gap: 2px;
position: absolute;
@@ -380,50 +380,50 @@
right: 0;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 6px;
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
padding: 2px;
}
.chat-msg--own .chat-msg__actions {
.ext-chat-msg--own .ext-chat-msg__actions {
right: auto;
left: 0;
}
.chat-msg__action {
.ext-chat-msg__action {
background: none;
border: none;
padding: 4px 6px;
padding: var(--sp-1) var(--sp-2);
cursor: pointer;
border-radius: 4px;
border-radius: var(--radius-sm);
font-size: 14px;
line-height: 1;
color: var(--text-2);
}
.chat-msg__action:hover {
.ext-chat-msg__action:hover {
background: var(--bg-hover);
}
.chat-msg__action--danger:hover {
.ext-chat-msg__action--danger:hover {
background: var(--danger-bg);
color: var(--danger);
}
/* ── Message Edit ───────────────────────── */
.chat-msg__edit {
.ext-chat-msg__edit {
display: flex;
flex-direction: column;
gap: 6px;
gap: var(--sp-2);
}
.chat-msg__edit-input {
.ext-chat-msg__edit-input {
width: 100%;
min-width: 200px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 8px;
border-radius: var(--radius);
padding: var(--sp-2) var(--sp-2);
font-size: 14px;
font-family: inherit;
resize: vertical;
@@ -431,28 +431,28 @@
color: var(--text);
}
.chat-msg__edit-actions {
.ext-chat-msg__edit-actions {
display: flex;
gap: 6px;
gap: var(--sp-2);
justify-content: flex-end;
}
/* ── Compose Bar ────────────────────────── */
.chat-compose {
.ext-chat-compose {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 12px 16px;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
border-top: 1px solid var(--border);
background: var(--bg);
}
.chat-compose__input {
.ext-chat-compose__input {
flex: 1;
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
border-radius: var(--radius);
padding: var(--sp-2) var(--sp-3);
font-size: 14px;
font-family: inherit;
resize: none;
@@ -462,7 +462,7 @@
color: var(--text);
}
.chat-compose__input:focus {
.ext-chat-compose__input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-dim);
@@ -470,7 +470,7 @@
/* ── Participant Sidebar ────────────────── */
.chat-participants {
.ext-chat-participants {
width: 240px;
min-width: 240px;
border-left: 1px solid var(--border);
@@ -479,30 +479,30 @@
background: var(--bg-secondary);
}
.chat-participants__header {
.ext-chat-participants__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 13px;
}
.chat-participants__list {
.ext-chat-participants__list {
flex: 1;
overflow-y: auto;
padding: 8px 0;
padding: var(--sp-2) 0;
}
.chat-participants__item {
.ext-chat-participants__item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-4);
}
.chat-participants__name {
.ext-chat-participants__name {
flex: 1;
font-size: 13px;
white-space: nowrap;
@@ -510,14 +510,14 @@
text-overflow: ellipsis;
}
.chat-participants__badge {
.ext-chat-participants__badge {
font-size: 10px;
color: var(--accent);
font-weight: 600;
margin-left: 4px;
margin-left: var(--sp-1);
}
.chat-participants__status {
.ext-chat-participants__status {
width: 8px;
height: 8px;
border-radius: 50%;
@@ -525,75 +525,75 @@
flex-shrink: 0;
}
.chat-participants__status--online {
.ext-chat-participants__status--online {
background: var(--success);
}
.chat-participants__remove {
.ext-chat-participants__remove {
background: none;
border: none;
color: var(--text-3);
cursor: pointer;
font-size: 16px;
padding: 0 4px;
padding: 0 var(--sp-1);
line-height: 1;
}
.chat-participants__remove:hover {
.ext-chat-participants__remove:hover {
color: var(--danger);
}
/* ── New Conversation Dialog ────────────── */
.chat-new {
.ext-chat-new {
display: flex;
flex-direction: column;
gap: 12px;
gap: var(--sp-3);
min-width: 320px;
}
.chat-new__type {
.ext-chat-new__type {
display: flex;
gap: 16px;
gap: var(--sp-4);
}
.chat-new__type label {
.ext-chat-new__type label {
display: flex;
align-items: center;
gap: 6px;
gap: var(--sp-2);
font-size: 13px;
cursor: pointer;
}
.chat-new__title {
.ext-chat-new__title {
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
border-radius: var(--radius);
padding: var(--sp-2) var(--sp-3);
font-size: 14px;
font-family: inherit;
background: var(--input-bg);
color: var(--text);
}
.chat-new__selected {
.ext-chat-new__selected {
display: flex;
flex-wrap: wrap;
gap: 6px;
gap: var(--sp-2);
}
.chat-new__chip {
.ext-chat-new__chip {
display: inline-flex;
align-items: center;
gap: 4px;
gap: var(--sp-1);
background: var(--accent-dim);
color: var(--accent);
font-size: 12px;
font-weight: 500;
padding: 3px 8px;
border-radius: 12px;
padding: 3px var(--sp-2);
border-radius: var(--radius-lg);
}
.chat-new__chip button {
.ext-chat-new__chip button {
background: none;
border: none;
color: inherit;
@@ -607,10 +607,10 @@
/* Allow the autocomplete dropdown to overflow the dialog body.
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;
}
.sw-dialog:has(.sw-user-picker) {
[data-ext="chat"] .sw-dialog:has(.sw-user-picker) {
overflow: visible;
}

View File

@@ -123,60 +123,60 @@
var sMsgs = showSearch ? (searchResults.messages || []) : [];
return html`
<div class="chat-sidebar">
<div class="chat-sidebar__header">
<span class="chat-sidebar__title">Conversations</span>
<div class="ext-chat-sidebar">
<div class="ext-chat-sidebar__header">
<span class="ext-chat-sidebar__title">Conversations</span>
<${Button} size="sm" onClick=${onNew}>New<//>
</div>
<div class="chat-sidebar__search">
<input class="chat-sidebar__search-input"
<div class="ext-chat-sidebar__search">
<input class="ext-chat-sidebar__search-input"
type="text"
value=${searchQuery}
placeholder="Search\u2026"
onInput=${handleSearchInput} />
${searchQuery && html`
<button class="chat-sidebar__search-clear" onClick=${clearSearch}>\u00d7</button>`}
<button class="ext-chat-sidebar__search-clear" onClick=${clearSearch}>\u00d7</button>`}
</div>
${showSearch ? html`
<div class="chat-sidebar__search-results">
${searching && html`<div class="chat-sidebar__search-loading"><${Spinner} size="sm" /></div>`}
<div class="ext-chat-sidebar__search-results">
${searching && html`<div class="ext-chat-sidebar__search-loading"><${Spinner} size="sm" /></div>`}
${!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`
<div class="chat-sidebar__search-section">Conversations</div>
<div class="ext-chat-sidebar__search-section">Conversations</div>
${sConvs.map(c => html`
<div key=${c.id} class="chat-sidebar__item" onClick=${() => selectFromSearch(c.id)}>
<div class="chat-sidebar__item-top">
<span class="chat-sidebar__item-title">${c.title || 'Untitled'}</span>
<span class="chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
<div key=${c.id} class="ext-chat-sidebar__item" onClick=${() => selectFromSearch(c.id)}>
<div class="ext-chat-sidebar__item-top">
<span class="ext-chat-sidebar__item-title">${c.title || 'Untitled'}</span>
<span class="ext-chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
</div>
</div>`)}`}
${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`
<div key=${m.id} class="chat-sidebar__item chat-sidebar__item--search-msg" onClick=${() => selectFromSearch(m.conversation_id)}>
<div class="chat-sidebar__item-top">
<span class="chat-sidebar__item-preview">${truncate(m.content, 80)}</span>
<div key=${m.id} class="ext-chat-sidebar__item ext-chat-sidebar__item--search-msg" onClick=${() => selectFromSearch(m.conversation_id)}>
<div class="ext-chat-sidebar__item-top">
<span class="ext-chat-sidebar__item-preview">${truncate(m.content, 80)}</span>
</div>
<div class="chat-sidebar__item-bottom">
<span class="chat-sidebar__item-time">${timeAgo(m.created_at)}</span>
<div class="ext-chat-sidebar__item-bottom">
<span class="ext-chat-sidebar__item-time">${timeAgo(m.created_at)}</span>
</div>
</div>`)}`}
</div>
` : html`
<div class="chat-sidebar__list">
<div class="ext-chat-sidebar__list">
${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`
<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)}>
<div class="chat-sidebar__item-top">
<span class="chat-sidebar__item-title">${c.title || 'Untitled'}</span>
<span class="chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
<div class="ext-chat-sidebar__item-top">
<span class="ext-chat-sidebar__item-title">${c.title || 'Untitled'}</span>
<span class="ext-chat-sidebar__item-time">${timeAgo(c.updated_at || c.created_at)}</span>
</div>
<div class="chat-sidebar__item-bottom">
<span class="chat-sidebar__item-preview">
<div class="ext-chat-sidebar__item-bottom">
<span class="ext-chat-sidebar__item-preview">
${c.last_message
? truncate(c.last_message.content_type === 'system'
? '\u2022 ' + c.last_message.content
@@ -184,7 +184,7 @@
: 'No messages yet'}
</span>
${(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>
@@ -204,14 +204,14 @@
if (msg._deleted) {
return html`
<div class="chat-msg chat-msg--deleted">
<div class="ext-chat-msg ext-chat-msg--deleted">
<em>This message was deleted</em>
</div>`;
}
if (msg.content_type === 'system') {
return html`
<div class="chat-msg chat-msg--system">
<div class="ext-chat-msg ext-chat-msg--system">
<span>${msg.content}</span>
</div>`;
}
@@ -239,37 +239,37 @@
}
return html`
<div class=${'chat-msg' + (isOwn ? ' chat-msg--own' : '')}
<div class=${'ext-chat-msg' + (isOwn ? ' ext-chat-msg--own' : '')}
onMouseEnter=${() => setHover(true)}
onMouseLeave=${() => setHover(false)}>
${!isOwn && html`
<${Avatar} name=${msg._display_name || msg.participant_id} size="sm" />`}
<div class="chat-msg__body">
${!isOwn && html`<span class="chat-msg__name">${msg._display_name || msg.participant_id}</span>`}
<${Avatar} name=${msg._display_name || 'Unknown'} size="sm" />`}
<div class="ext-chat-msg__body">
${!isOwn && html`<span class="ext-chat-msg__name">${msg._display_name || 'Unknown'}</span>`}
${editing ? html`
<div class="chat-msg__edit">
<textarea class="chat-msg__edit-input"
<div class="ext-chat-msg__edit">
<textarea class="ext-chat-msg__edit-input"
value=${editText}
onInput=${e => setEditText(e.target.value)}
onKeyDown=${onEditKeyDown}
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" onClick=${saveEdit}>Save<//>
</div>
</div>
` : 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="chat-msg__content">${msg.content}</div>`}
<div class="chat-msg__meta">
<span class="chat-msg__time">${timeAgo(msg.created_at)}</span>
${msg.edited_at && html`<span class="chat-msg__edited">(edited)</span>`}
<div class="ext-chat-msg__content" dangerouslySetInnerHTML=${{ __html: sw.markdown.renderSync(msg.content, { sanitize: true }) }} />` : html`
<div class="ext-chat-msg__content">${msg.content}</div>`}
<div class="ext-chat-msg__meta">
<span class="ext-chat-msg__time">${timeAgo(msg.created_at)}</span>
${msg.edited_at && html`<span class="ext-chat-msg__edited">(edited)</span>`}
</div>
</div>
${hover && isOwn && !editing && html`
<div class="chat-msg__actions">
<button class="chat-msg__action" onClick=${startEdit} title="Edit">&#9998;</button>
<button class="chat-msg__action chat-msg__action--danger" onClick=${() => onDelete(msg.id)} title="Delete">&#128465;</button>
<div class="ext-chat-msg__actions">
<button class="ext-chat-msg__action" onClick=${startEdit} title="Edit">&#9998;</button>
<button class="ext-chat-msg__action ext-chat-msg__action--danger" onClick=${() => onDelete(msg.id)} title="Delete">&#128465;</button>
</div>`}
</div>`;
}
@@ -285,20 +285,32 @@
var [hasMore, setHasMore] = useState(false);
var [nextCursor, setNextCursor] = useState('');
var [typingUsers, setTypingUsers] = useState({});
var [resolvedNames, setResolvedNames] = useState({});
var bottomRef = useRef(null);
var listRef = useRef(null);
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 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;
}, [participants]);
}, [participants, resolvedNames]);
// Enrich messages with display names
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
@@ -361,7 +373,7 @@
var unsubs = [
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]);
setTimeout(() => scrollToBottom(), 50);
// Auto mark read if from someone else
@@ -386,7 +398,8 @@
unsubs.push(sw.realtime.subscribe(channel, 'typing', (payload) => {
var pid = payload.participant_id;
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]);
typingTimers[pid] = setTimeout(() => {
setTypingUsers(prev => {
@@ -442,18 +455,18 @@
if (!conversationId) {
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>
</div>`;
}
return html`
<div class="chat-thread">
<div class="chat-thread__messages" ref=${listRef}>
${loading && messages.length === 0 && html`<div class="chat-thread__loading"><${Spinner} /></div>`}
${loading && messages.length > 0 && html`<div class="chat-thread__loading-more"><${Spinner} size="sm" /></div>`}
<div class="ext-chat-thread">
<div class="ext-chat-thread__messages" ref=${listRef}>
${loading && messages.length === 0 && html`<div class="ext-chat-thread__loading"><${Spinner} /></div>`}
${loading && messages.length > 0 && html`<div class="ext-chat-thread__loading-more"><${Spinner} size="sm" /></div>`}
${hasMore && !loading && html`
<button class="chat-thread__load-more" onClick=${loadMore}>
<button class="ext-chat-thread__load-more" onClick=${loadMore}>
Load older messages
</button>`}
${messages.map(m => html`
@@ -466,7 +479,7 @@
/>`)}
<div ref=${bottomRef} />
</div>
${typingText && html`<div class="chat-thread__typing">${typingText}</div>`}
${typingText && html`<div class="ext-chat-thread__typing">${typingText}</div>`}
</div>`;
}
@@ -533,8 +546,8 @@
if (!conversationId) return null;
return html`
<div class="chat-compose">
<textarea class="chat-compose__input"
<div class="ext-chat-compose">
<textarea class="ext-chat-compose__input"
ref=${textareaRef}
value=${text}
placeholder="Type a message\u2026"
@@ -553,6 +566,18 @@
function ParticipantSidebar({ conversationId, participants, onRefresh, isAdmin }) {
var [addOpen, setAddOpen] = useState(false);
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
useEffect(() => {
@@ -580,22 +605,22 @@
}
return html`
<div class="chat-participants">
<div class="chat-participants__header">
<div class="ext-chat-participants">
<div class="ext-chat-participants__header">
<span>Participants (${(participants || []).length})</span>
${isAdmin && html`<${Button} size="sm" onClick=${() => setAddOpen(true)}>Add<//>` }
</div>
<div class="chat-participants__list">
<div class="ext-chat-participants__list">
${(participants || []).map(p => html`
<div key=${p.participant_id} class="chat-participants__item">
<${Avatar} name=${p.display_name || p.participant_id} size="sm" />
<span class="chat-participants__name">
${p.display_name || p.participant_id}
${p.role === 'admin' && html`<span class="chat-participants__badge">admin</span>`}
<div key=${p.participant_id} class="ext-chat-participants__item">
<${Avatar} name=${resolvedNames[p.participant_id] || p.display_name || 'Unknown'} size="sm" />
<span class="ext-chat-participants__name">
${resolvedNames[p.participant_id] || p.display_name || 'Unknown'}
${p.role === 'admin' && html`<span class="ext-chat-participants__badge">admin</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`
<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>
@@ -671,8 +696,8 @@
return html`
<${Dialog} open=${open} title="New Conversation" onClose=${() => { reset(); onClose(); }} actions=${actions}>
<div class="chat-new">
<div class="chat-new__type">
<div class="ext-chat-new">
<div class="ext-chat-new__type">
<label>
<input type="radio" name="convType" value="group"
checked=${type === 'group'} onChange=${() => { setType('group'); setSelected([]); }} />
@@ -685,14 +710,14 @@
</label>
</div>
${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)"
onInput=${e => setTitle(e.target.value)} />`}
<${UserPicker} onSelect=${addUser} placeholder=${type === 'direct' ? 'Search for a user\u2026' : 'Add participants\u2026'} />
${selected.length > 0 && html`
<div class="chat-new__selected">
<div class="ext-chat-new__selected">
${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}
<button onClick=${() => removeSelected(u.id)}>\u00d7</button>
</span>`)}
@@ -807,27 +832,27 @@
var threadTitle = selectedConv ? (selectedConv.title || 'Direct Message') : '';
if (loading) {
return html`<div class="chat-loading"><${Spinner} /></div>`;
return html`<div class="ext-chat-loading"><${Spinner} /></div>`;
}
return html`
<div class="chat-app">
<div class="ext-chat-app">
<${Topbar} title="Chat">
${selectedId && html`
<span class="chat-topbar__thread-title">${threadTitle}</span>
<span class="ext-chat-topbar__thread-title">${threadTitle}</span>
<${Button} size="sm" variant="secondary"
onClick=${() => setShowParticipants(!showParticipants)}>
${showParticipants ? 'Hide' : 'People'}
<//>`}
<//>
<div class="chat-body">
<div class="ext-chat-body">
<${ConversationList}
selected=${selectedId}
onSelect=${selectConversation}
onNew=${() => setShowNew(true)}
conversations=${conversations}
unread=${unread} />
<div class="chat-main">
<div class="ext-chat-main">
<${MessageThread}
conversationId=${selectedId}
participants=${participants} />

View File

@@ -6,63 +6,63 @@
All SDK components style themselves.
========================================== */
.surface-dashboard {
.ext-dashboard {
display: flex;
flex-direction: column;
height: 100vh;
height: 100%;
overflow: hidden;
}
/* ── Topbar ──────────────────────────────── */
.dashboard-topbar {
.ext-dashboard-topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 0 16px;
gap: var(--sp-3);
padding: 0 var(--sp-4);
height: 44px;
flex-shrink: 0;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.dashboard-topbar-back {
.ext-dashboard-topbar-back {
display: flex;
align-items: center;
gap: 4px;
gap: var(--sp-1);
color: var(--text-2);
text-decoration: none;
font-size: 12px;
font-weight: 500;
padding: 4px 8px;
padding: var(--sp-1) var(--sp-2);
border-radius: var(--radius);
transition: color 0.15s, background 0.15s;
}
.dashboard-topbar-back:hover {
.ext-dashboard-topbar-back:hover {
color: var(--text);
background: var(--bg-hover);
}
.dashboard-topbar-title {
.ext-dashboard-topbar-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.dashboard-topbar-sep {
.ext-dashboard-topbar-sep {
width: 1px;
height: 18px;
background: var(--border);
}
.dashboard-topbar-spacer {
.ext-dashboard-topbar-spacer {
flex: 1;
}
/* ── Body ────────────────────────────────── */
.dashboard-body {
.ext-dashboard-body {
display: flex;
flex: 1;
min-height: 0;
@@ -70,7 +70,7 @@
/* ── Sidebar ─────────────────────────────── */
.dashboard-sidebar {
.ext-dashboard-sidebar {
width: 300px;
flex-shrink: 0;
display: flex;
@@ -81,34 +81,34 @@
/* ── Main Content ────────────────────────── */
.dashboard-main {
.ext-dashboard-main {
flex: 1;
overflow-y: auto;
padding: 20px;
padding: var(--sp-5);
min-width: 0;
}
.dashboard-greeting {
.ext-dashboard-greeting {
font-size: 18px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
margin-bottom: var(--sp-1);
}
.dashboard-subtitle {
.ext-dashboard-subtitle {
font-size: 13px;
color: var(--text-3);
margin-bottom: 20px;
margin-bottom: var(--sp-5);
}
.dashboard-filter-bar {
.ext-dashboard-filter-bar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.dashboard-filter-label {
.ext-dashboard-filter-label {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
@@ -118,34 +118,34 @@
/* ── Cards Grid ──────────────────────────── */
.dashboard-cards {
.ext-dashboard-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
gap: var(--sp-3);
}
.dashboard-card {
.ext-dashboard-card {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
padding: var(--sp-4);
display: flex;
flex-direction: column;
gap: 8px;
gap: var(--sp-2);
transition: border-color 0.15s;
}
.dashboard-card:hover {
.ext-dashboard-card:hover {
border-color: var(--border-elevated);
}
.dashboard-card-header {
.ext-dashboard-card-header {
display: flex;
align-items: center;
gap: 8px;
gap: var(--sp-2);
}
.dashboard-card-title {
.ext-dashboard-card-title {
font-size: 14px;
font-weight: 600;
color: var(--text);
@@ -155,43 +155,43 @@
white-space: nowrap;
}
.dashboard-card-meta {
.ext-dashboard-card-meta {
font-size: 11px;
color: var(--text-3);
}
.dashboard-card-desc {
.ext-dashboard-card-desc {
font-size: 12px;
color: var(--text-2);
line-height: 1.4;
}
.dashboard-card-actions {
.ext-dashboard-card-actions {
display: flex;
justify-content: flex-end;
margin-top: 4px;
margin-top: var(--sp-1);
}
.dashboard-empty {
.ext-dashboard-empty {
text-align: center;
color: var(--text-3);
font-size: 13px;
padding: 40px 20px;
padding: var(--sp-10) var(--sp-5);
}
/* ── Admin Section ───────────────────────── */
.dashboard-admin-section {
margin-top: 24px;
padding-top: 16px;
.ext-dashboard-admin-section {
margin-top: var(--sp-6);
padding-top: var(--sp-4);
border-top: 1px solid var(--border);
}
.dashboard-section-title {
.ext-dashboard-section-title {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
margin-bottom: var(--sp-3);
}

View File

@@ -28,7 +28,7 @@
if (!mount) return;
const surface = document.createElement('div');
surface.className = 'surface-dashboard';
surface.className = 'ext-dashboard';
surface.id = 'dashboardSurface';
mount.appendChild(surface);
@@ -41,12 +41,12 @@
// ── Body ──
const body = document.createElement('div');
body.className = 'dashboard-body';
body.className = 'ext-dashboard-body';
surface.appendChild(body);
// ── Sidebar (tabs: activity + notes) ──
const sidebar = document.createElement('div');
sidebar.className = 'dashboard-sidebar';
sidebar.className = 'ext-dashboard-sidebar';
body.appendChild(sidebar);
// sw.tabs — two tabs
@@ -83,14 +83,14 @@
// ── Main content area ──
const main = document.createElement('div');
main.className = 'dashboard-main';
main.className = 'ext-dashboard-main';
body.appendChild(main);
_buildMainContent(main);
// ── Theme reactivity ──
sw.theme.on('change', function (resolved) {
const cards = main.querySelectorAll('.dashboard-card');
const cards = main.querySelectorAll('.ext-dashboard-card');
cards.forEach(function (c) {
c.style.borderColor = ''; // reset to CSS default for new theme
});
@@ -98,7 +98,7 @@
// ── Cross-component events ──
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');
@@ -108,15 +108,15 @@
function _buildTopbar() {
const el = document.createElement('div');
el.className = 'dashboard-topbar';
el.className = 'ext-dashboard-topbar';
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>' +
'Back' +
'</a>' +
'<div class="dashboard-topbar-sep"></div>' +
'<span class="dashboard-topbar-title">Dashboard</span>' +
'<div class="dashboard-topbar-spacer"></div>';
'<div class="ext-dashboard-topbar-sep"></div>' +
'<span class="ext-dashboard-topbar-title">Dashboard</span>' +
'<div class="ext-dashboard-topbar-spacer"></div>';
// sw.toolbar — action buttons
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>',
title: 'Refresh',
onClick: function () {
const cards = document.querySelector('.dashboard-cards');
const cards = document.querySelector('.ext-dashboard-cards');
if (cards) _loadChannels(cards, _currentFilter);
sw.toast('Refreshed', 'success');
},
@@ -155,22 +155,22 @@
// sw.user — greeting
const user = sw.user;
const greeting = document.createElement('div');
greeting.className = 'dashboard-greeting';
greeting.textContent = 'Welcome back' + (user ? ', ' + (user.display_name || user.username) : '');
greeting.className = 'ext-dashboard-greeting';
greeting.textContent = 'Welcome back' + (user ? ', ' + (user.display_name || user.username || 'Unknown') : '');
main.appendChild(greeting);
const subtitle = document.createElement('div');
subtitle.className = 'dashboard-subtitle';
subtitle.className = 'ext-dashboard-subtitle';
subtitle.textContent = 'Your recent conversations' + (sw.isAdmin ? ' \u00b7 Admin' : '');
main.appendChild(subtitle);
// ── Filter bar ──
const filterBar = document.createElement('div');
filterBar.className = 'dashboard-filter-bar';
filterBar.className = 'ext-dashboard-filter-bar';
main.appendChild(filterBar);
const filterLabel = document.createElement('span');
filterLabel.className = 'dashboard-filter-label';
filterLabel.className = 'ext-dashboard-filter-label';
filterLabel.textContent = 'Filter';
filterBar.appendChild(filterLabel);
@@ -192,7 +192,7 @@
// ── Cards ──
const cards = document.createElement('div');
cards.className = 'dashboard-cards';
cards.className = 'ext-dashboard-cards';
main.appendChild(cards);
_loadChannels(cards, '');
@@ -200,16 +200,16 @@
// ── Admin section (sw.isAdmin) ──
if (sw.isAdmin) {
const adminSection = document.createElement('div');
adminSection.className = 'dashboard-admin-section';
adminSection.className = 'ext-dashboard-admin-section';
main.appendChild(adminSection);
const sectionTitle = document.createElement('div');
sectionTitle.className = 'dashboard-section-title';
sectionTitle.className = 'ext-dashboard-section-title';
sectionTitle.textContent = 'Administration';
adminSection.appendChild(sectionTitle);
const adminCards = document.createElement('div');
adminCards.className = 'dashboard-cards';
adminCards.className = 'ext-dashboard-cards';
adminSection.appendChild(adminCards);
_loadAdminCards(adminCards);
@@ -220,7 +220,7 @@
async function _loadChannels(container, typeFilter) {
if (!container) return;
container.innerHTML = '<div class="dashboard-empty">Loading\u2026</div>';
container.innerHTML = '<div class="ext-dashboard-empty">Loading\u2026</div>';
try {
// sw.api — real API call
@@ -231,7 +231,7 @@
container.innerHTML = '';
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;
}
@@ -239,31 +239,31 @@
container.appendChild(_buildChannelCard(ch));
});
} 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) {
const card = document.createElement('div');
card.className = 'dashboard-card';
card.className = 'ext-dashboard-card';
const header = document.createElement('div');
header.className = 'dashboard-card-header';
header.className = 'ext-dashboard-card-header';
card.appendChild(header);
const title = document.createElement('div');
title.className = 'dashboard-card-title';
title.className = 'ext-dashboard-card-title';
title.textContent = ch.title || ch.name || 'Untitled';
header.appendChild(title);
const meta = document.createElement('div');
meta.className = 'dashboard-card-meta';
meta.className = 'ext-dashboard-card-meta';
meta.textContent = ch.type || '';
card.appendChild(meta);
if (ch.description) {
const desc = document.createElement('div');
desc.className = 'dashboard-card-desc';
desc.className = 'ext-dashboard-card-desc';
desc.textContent = ch.description.slice(0, 120);
card.appendChild(desc);
}
@@ -271,14 +271,14 @@
const updated = ch.updated_at || ch.created_at;
if (updated) {
const date = document.createElement('div');
date.className = 'dashboard-card-meta';
date.className = 'ext-dashboard-card-meta';
date.textContent = new Date(updated).toLocaleDateString();
card.appendChild(date);
}
// ── Card action menu ──
const actions = document.createElement('div');
actions.className = 'dashboard-card-actions';
actions.className = 'ext-dashboard-card-actions';
card.appendChild(actions);
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.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;">' +
'<button class="btn-small" id="dashDetailClose">Close</button>' +
'<button class="btn-small btn-primary" id="dashDetailOpen">Open Chat</button>' +
'<button class="sw-btn sw-btn--secondary sw-btn--sm" id="dashDetailClose">Close</button>' +
'<button class="sw-btn sw-btn--primary sw-btn--sm" id="dashDetailOpen">Open Chat</button>' +
'</div>';
modal.appendChild(box);
@@ -372,7 +372,7 @@
// sw.toast — success feedback
sw.toast('Channel deleted', 'success');
// Refresh cards
const cards = document.querySelector('.dashboard-cards');
const cards = document.querySelector('.ext-dashboard-cards');
if (cards) _loadChannels(cards, _currentFilter);
} catch (e) {
// sw.toast — error feedback
@@ -390,21 +390,21 @@
container.innerHTML = '';
packages.forEach(function (pkg) {
const card = document.createElement('div');
card.className = 'dashboard-card';
card.className = 'ext-dashboard-card';
card.innerHTML =
'<div class="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-header">' +
'<div class="ext-dashboard-card-title">' + (typeof esc === 'function' ? esc(pkg.title || pkg.id) : (pkg.title || pkg.id)) + '</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="dashboard-card-desc">' + (typeof esc === 'function' ? esc(pkg.description || '') : (pkg.description || '')) + '</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="ext-dashboard-card-desc">' + (typeof esc === 'function' ? esc(pkg.description || '') : (pkg.description || '')) + '</div>';
container.appendChild(card);
});
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 (_) {
container.innerHTML = '<div class="dashboard-empty">Failed to load packages</div>';
container.innerHTML = '<div class="ext-dashboard-empty">Failed to load packages</div>';
}
}

View File

@@ -8,166 +8,166 @@
/* ── Surface Shell ─────────────────────────── */
.surface-editor {
.ext-editor {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--bg, #0e0e10);
background: var(--bg);
}
/* ── Topbar ────────────────────────────────── */
.editor-topbar {
.ext-editor-topbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
gap: var(--sp-2);
padding: 0 var(--sp-3);
height: 40px;
flex-shrink: 0;
background: var(--bg-secondary, #151517);
border-bottom: 1px solid var(--border, #2a2a2e);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
font-size: 13px;
position: relative;
z-index: 20;
}
.editor-topbar-back {
.ext-editor-topbar-back {
display: flex;
align-items: center;
gap: 4px;
color: var(--text-3, #777);
gap: var(--sp-1);
color: var(--text-3);
text-decoration: none;
font-size: 12px;
font-weight: 500;
padding: 4px 8px;
border-radius: 4px;
padding: var(--sp-1) var(--sp-2);
border-radius: var(--radius-sm);
transition: color 0.15s, background 0.15s;
}
.editor-topbar-back:hover {
color: var(--text, #eee);
.ext-editor-topbar-back:hover {
color: var(--text);
background: var(--bg-hover);
}
.editor-topbar-sep {
.ext-editor-topbar-sep {
width: 1px;
height: 18px;
background: var(--border, #2a2a2e);
background: var(--border);
}
.editor-topbar-name {
.ext-editor-topbar-name {
font-size: 13px;
font-weight: 600;
color: var(--text, #eee);
color: var(--text);
}
/* ── Workspace Selector ────────────────────── */
.editor-ws-selector {
.ext-editor-ws-selector {
position: relative;
}
.editor-ws-selector-btn {
.ext-editor-ws-selector-btn {
display: flex;
align-items: center;
gap: 6px;
gap: var(--sp-2);
background: none;
border: 1px solid transparent;
color: var(--text, #eee);
color: var(--text);
font-size: 13px;
font-weight: 600;
font-family: inherit;
padding: 4px 8px;
border-radius: 4px;
padding: var(--sp-1) var(--sp-2);
border-radius: var(--radius-sm);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.editor-ws-selector-btn:hover {
border-color: var(--border, #2a2a2e);
.ext-editor-ws-selector-btn:hover {
border-color: var(--border);
background: var(--bg-hover);
}
.editor-ws-dropdown {
.ext-editor-ws-dropdown {
display: none;
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--bg-secondary, #1a1a1e);
border: 1px solid var(--border, #2a2a2e);
border-radius: 8px;
margin-top: var(--sp-1);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
min-width: 220px;
max-height: 320px;
overflow-y: auto;
z-index: 1000;
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;
overflow-y: auto;
}
.editor-ws-dropdown-item {
.ext-editor-ws-dropdown-item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
color: var(--text, #eee);
color: var(--text);
font-size: 12px;
font-family: inherit;
padding: 8px 12px;
padding: var(--sp-2) var(--sp-3);
cursor: pointer;
transition: background 0.1s;
}
.editor-ws-dropdown-item:hover {
.ext-editor-ws-dropdown-item:hover {
background: var(--bg-hover);
}
.editor-ws-dropdown-item.active {
color: var(--accent, #b38a4e);
.ext-editor-ws-dropdown-item.active {
color: var(--accent);
font-weight: 600;
}
.editor-ws-dropdown-divider {
.ext-editor-ws-dropdown-divider {
height: 1px;
background: var(--border, #2a2a2e);
margin: 4px 0;
background: var(--border);
margin: var(--sp-1) 0;
}
.editor-ws-new {
.ext-editor-ws-new {
display: flex;
align-items: center;
gap: 6px;
color: var(--accent, #b38a4e);
gap: var(--sp-2);
color: var(--accent);
}
.editor-topbar-branch {
.ext-editor-topbar-branch {
display: flex;
align-items: center;
gap: 4px;
background: var(--purple-dim, rgba(160, 120, 255, 0.1));
padding: 2px 8px;
border-radius: 4px;
gap: var(--sp-1);
background: var(--purple-dim);
padding: 2px var(--sp-2);
border-radius: var(--radius-sm);
}
.editor-topbar-branch-text {
.ext-editor-topbar-branch-text {
font-size: 11px;
font-weight: 600;
color: var(--purple, #a078ff);
color: var(--purple);
font-family: var(--mono, 'SF Mono', monospace);
}
/* ── Body ──────────────────────────────────── */
.editor-body {
.ext-editor-body {
flex: 1;
min-height: 0;
overflow: hidden;
@@ -175,47 +175,47 @@
/* ── Bootstrap (no workspace) ──────────────── */
.editor-bootstrap {
.ext-editor-bootstrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.editor-bootstrap-card {
.ext-editor-bootstrap-card {
text-align: center;
padding: 40px;
background: var(--bg-secondary, #151517);
border: 1px solid var(--border, #2a2a2e);
border-radius: 12px;
padding: var(--sp-10);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
max-width: 360px;
}
.editor-bootstrap-input {
.ext-editor-bootstrap-input {
width: 100%;
padding: 8px 12px;
background: var(--bg, #0e0e10);
border: 1px solid var(--border, #2a2a2e);
border-radius: 6px;
color: var(--text, #eee);
padding: var(--sp-2) var(--sp-3);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
font-family: inherit;
outline: none;
margin-bottom: 12px;
margin-bottom: var(--sp-3);
box-sizing: border-box;
}
.editor-bootstrap-input:focus {
border-color: var(--accent, #b38a4e);
.ext-editor-bootstrap-input:focus {
border-color: var(--accent);
}
.editor-bootstrap-btn {
.ext-editor-bootstrap-btn {
width: 100%;
padding: 10px 16px;
background: var(--accent, #b38a4e);
padding: var(--sp-3) var(--sp-4);
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
border-radius: var(--radius);
font-size: 13px;
font-weight: 600;
font-family: inherit;
@@ -223,108 +223,108 @@
transition: opacity 0.15s;
}
.editor-bootstrap-btn:hover { opacity: 0.9; }
.editor-bootstrap-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.ext-editor-bootstrap-btn:hover { opacity: 0.9; }
.ext-editor-bootstrap-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Workspace list in bootstrap */
.editor-bootstrap-ws-item {
.ext-editor-bootstrap-ws-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 10px 12px;
background: var(--bg, #0e0e10);
border: 1px solid var(--border, #2a2a2e);
border-radius: 6px;
color: var(--text, #eee);
padding: var(--sp-3) var(--sp-3);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
margin-bottom: 6px;
margin-bottom: var(--sp-2);
text-align: left;
}
.editor-bootstrap-ws-item:hover {
border-color: var(--accent, #b38a4e);
.ext-editor-bootstrap-ws-item:hover {
border-color: var(--accent);
background: var(--bg-hover);
}
.editor-bootstrap-ws-name {
.ext-editor-bootstrap-ws-name {
font-weight: 600;
}
.editor-bootstrap-ws-date {
.ext-editor-bootstrap-ws-date {
font-size: 11px;
color: var(--text-3, #777);
color: var(--text-3);
}
/* ── FileTree overrides (in editor context) ── */
.surface-editor .file-tree {
.ext-editor .file-tree {
height: 100%;
display: flex;
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;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--border, #2a2a2e);
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
}
.surface-editor .file-tree-title {
.ext-editor .file-tree-title {
font-size: 11px;
font-weight: 600;
color: var(--text-2, #999);
color: var(--text-2);
text-transform: uppercase;
letter-spacing: 0.4px;
flex: 1;
}
.surface-editor .file-tree-items {
.ext-editor .file-tree-items {
flex: 1;
overflow-y: auto;
padding: 4px 0;
padding: var(--sp-1) 0;
}
.surface-editor .file-tree-row {
.ext-editor .file-tree-row {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
gap: var(--sp-1);
padding: 3px var(--sp-2);
cursor: pointer;
font-size: 12px;
color: var(--text-2, #999);
color: var(--text-2);
transition: background 0.1s;
}
.surface-editor .file-tree-row:hover {
.ext-editor .file-tree-row:hover {
background: var(--bg-hover);
}
.surface-editor .file-tree-row.active {
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
color: var(--text, #eee);
.ext-editor .file-tree-row.active {
background: var(--accent-dim);
color: var(--text);
}
.surface-editor .file-tree-arrow {
.ext-editor .file-tree-arrow {
width: 12px;
font-size: 10px;
color: var(--text-3, #555);
color: var(--text-3);
text-align: center;
}
.surface-editor .file-tree-icon {
.ext-editor .file-tree-icon {
font-size: 13px;
width: 18px;
text-align: center;
}
.surface-editor .file-tree-name {
.ext-editor .file-tree-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
@@ -332,134 +332,134 @@
}
/* Git status indicators */
.surface-editor .file-tree-row.git-modified .file-tree-name { color: var(--warning, #e5a842); }
.surface-editor .file-tree-row.git-added .file-tree-name { color: var(--success, #4caf50); }
.surface-editor .file-tree-row.git-untracked .file-tree-name { color: var(--text-3, #555); 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-modified .file-tree-name { color: var(--warning); }
.ext-editor .file-tree-row.git-added .file-tree-name { color: var(--success); }
.ext-editor .file-tree-row.git-untracked .file-tree-name { color: var(--text-3); font-style: italic; }
.ext-editor .file-tree-row.git-deleted .file-tree-name { color: var(--danger); text-decoration: line-through; }
/* Context menu */
.file-tree-ctx-menu {
.ext-editor-file-tree-ctx-menu {
position: fixed;
background: var(--bg-secondary, #1a1a1e);
border: 1px solid var(--border, #2a2a2e);
border-radius: 6px;
padding: 4px 0;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--sp-1) 0;
min-width: 120px;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.file-tree-ctx-item {
padding: 6px 12px;
.ext-editor-file-tree-ctx-item {
padding: var(--sp-2) var(--sp-3);
font-size: 12px;
color: var(--text, #eee);
color: var(--text);
cursor: pointer;
}
.file-tree-ctx-item:hover {
.ext-editor-file-tree-ctx-item:hover {
background: var(--bg-hover);
}
/* ── CodeEditor overrides ──────────────────── */
.surface-editor .code-editor {
.ext-editor .code-editor {
height: 100%;
display: flex;
flex-direction: column;
}
.surface-editor .code-editor-tabs {
.ext-editor .code-editor-tabs {
display: flex;
align-items: center;
gap: 0;
background: var(--bg-secondary, #151517);
border-bottom: 1px solid var(--border, #2a2a2e);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
height: 32px;
overflow-x: auto;
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;
align-items: center;
gap: 4px;
padding: 0 12px;
gap: var(--sp-1);
padding: 0 var(--sp-3);
height: 100%;
font-size: 12px;
color: var(--text-3, #777);
color: var(--text-3);
cursor: pointer;
border-right: 1px solid var(--border, #2a2a2e);
border-right: 1px solid var(--border);
transition: background 0.1s;
white-space: nowrap;
}
.surface-editor .code-editor-tab:hover { background: var(--bg-hover); }
.surface-editor .code-editor-tab.active { color: var(--text, #eee); background: var(--bg, #0e0e10); }
.surface-editor .code-editor-tab.modified .code-editor-tab-modified { color: var(--warning, #e5a842); }
.ext-editor .code-editor-tab:hover { background: var(--bg-hover); }
.ext-editor .code-editor-tab.active { color: var(--text); background: var(--bg); }
.ext-editor .code-editor-tab.modified .code-editor-tab-modified { color: var(--warning); }
.surface-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-icon { font-size: 12px; }
.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;
border: none;
color: var(--text-3, #555);
color: var(--text-3);
font-size: 12px;
cursor: pointer;
padding: 0 2px;
margin-left: 4px;
border-radius: 2px;
margin-left: var(--sp-1);
border-radius: var(--radius-sm);
line-height: 1;
}
.surface-editor .code-editor-tab-close:hover {
background: var(--danger-dim, rgba(244, 67, 54, 0.15));
color: var(--danger, #f44336);
.ext-editor .code-editor-tab-close:hover {
background: var(--danger-dim);
color: var(--danger);
}
.surface-editor .code-editor-content {
.ext-editor .code-editor-content {
flex: 1;
min-height: 0;
overflow: hidden;
position: relative;
}
.surface-editor .code-editor-welcome {
.ext-editor .code-editor-welcome {
height: 100%;
}
.surface-editor .code-editor-cm-wrap {
.ext-editor .code-editor-cm-wrap {
height: 100%;
overflow: auto;
}
.surface-editor .code-editor-cm-wrap .cm-editor {
.ext-editor .code-editor-cm-wrap .cm-editor {
height: 100%;
}
.surface-editor .code-editor-statusbar {
.ext-editor .code-editor-statusbar {
display: flex;
align-items: center;
gap: 16px;
padding: 0 12px;
gap: var(--sp-4);
padding: 0 var(--sp-3);
height: 24px;
flex-shrink: 0;
background: var(--bg-secondary, #151517);
border-top: 1px solid var(--border, #2a2a2e);
background: var(--bg-secondary);
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-3, #777);
color: var(--text-3);
font-family: var(--mono, 'SF Mono', monospace);
}
.surface-editor .code-editor-textarea-fallback {
.ext-editor .code-editor-textarea-fallback {
width: 100%;
height: 100%;
background: var(--bg, #0e0e10);
color: var(--text, #eee);
background: var(--bg);
color: var(--text);
border: none;
padding: 12px;
padding: var(--sp-3);
font-family: var(--mono, 'SF Mono', monospace);
font-size: 13px;
resize: none;
@@ -469,18 +469,18 @@
/* ── Tabbed assist pane overrides ──────────── */
.surface-editor .pane-tabbed {
border-left: 1px solid var(--border, #2a2a2e);
.ext-editor .pane-tabbed {
border-left: 1px solid var(--border);
}
/* ChatPane in editor tabbed pane */
.surface-editor .chat-pane {
.ext-editor .chat-pane {
position: absolute;
inset: 0;
}
/* Notes in editor pane */
.surface-editor .note-editor {
.ext-editor .ext-notes-editor {
position: absolute;
inset: 0;
display: flex;
@@ -488,43 +488,43 @@
overflow: hidden;
}
.surface-editor .note-editor-list-view {
.ext-editor .ext-notes-editor-list-view {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.surface-editor .notes-list {
.ext-editor .ext-notes-list {
flex: 1;
overflow-y: auto;
}
/* Compact notes toolbar for narrow pane */
.surface-editor .notes-toolbar {
.ext-editor .ext-notes-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 8px;
border-bottom: 1px solid var(--border, #2a2a2e);
gap: var(--sp-1);
padding: var(--sp-2) var(--sp-2);
border-bottom: 1px solid var(--border);
}
.surface-editor .notes-toolbar .btn-small {
.ext-editor .ext-notes-toolbar .sw-btn--sm {
font-size: 11px;
padding: 3px 6px;
padding: 3px var(--sp-2);
}
.surface-editor .notes-search-row {
padding: 4px 8px;
.ext-editor .ext-notes-search-row {
padding: var(--sp-1) var(--sp-2);
}
.surface-editor .notes-filter-row {
padding: 2px 8px 4px;
.ext-editor .ext-notes-filter-row {
padding: 2px var(--sp-2) var(--sp-1);
display: flex;
gap: 4px;
gap: var(--sp-1);
}
.surface-editor .notes-filter-select {
.ext-editor .ext-notes-filter-select {
font-size: 11px;
flex: 1;
min-width: 0;

View File

@@ -56,7 +56,7 @@
// Wrap in surface container for CSS scoping
const surface = document.createElement('div');
surface.className = 'surface-editor';
surface.className = 'ext-editor';
surface.id = 'editorSurface';
mount.appendChild(surface);
@@ -84,7 +84,7 @@
// Build body + bootstrap
const body = document.createElement('div');
body.className = 'editor-body';
body.className = 'ext-editor-body';
body.id = 'editorBody';
surface.appendChild(body);
@@ -113,32 +113,32 @@
function _buildTopbar(wsName) {
const el = document.createElement('div');
el.className = 'editor-topbar';
el.className = 'ext-editor-topbar';
el.id = 'editorTopbar';
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>' +
'Back' +
'</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>' +
'<div class="editor-ws-selector" id="editorWsSelector">' +
'<button class="editor-ws-selector-btn" id="editorWsSelectorBtn">' +
'<div class="ext-editor-ws-selector" id="editorWsSelector">' +
'<button class="ext-editor-ws-selector-btn" id="editorWsSelectorBtn">' +
'<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>' +
'</button>' +
'<div class="editor-ws-dropdown" id="editorWsDropdown">' +
'<div id="editorWsList" class="editor-ws-list"></div>' +
'<div class="editor-ws-dropdown-divider"></div>' +
'<button class="editor-ws-dropdown-item editor-ws-new" id="editorWsNewBtn">' +
'<div class="ext-editor-ws-dropdown" id="editorWsDropdown">' +
'<div id="editorWsList" class="ext-editor-ws-list"></div>' +
'<div class="ext-editor-ws-dropdown-divider"></div>' +
'<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>' +
'New Workspace' +
'</button>' +
'</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>' +
'<span id="editorBranchName" class="editor-topbar-branch-text">main</span>' +
'<span id="editorBranchName" class="ext-editor-topbar-branch-text">main</span>' +
'</div>' +
'<div style="flex:1;"></div>' +
'<button class="icon-btn" id="editorRefreshBtn" title="Refresh files">' +
@@ -151,11 +151,11 @@
function _buildBootstrap() {
const el = document.createElement('div');
el.className = 'editor-bootstrap';
el.className = 'ext-editor-bootstrap';
el.id = 'editorBootstrap';
el.style.display = 'none';
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;">' +
'<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>' +
@@ -168,8 +168,8 @@
'<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>' +
'<input type="text" id="editorBootstrapName" class="editor-bootstrap-input" placeholder="Workspace name" value="workspace">' +
'<button id="editorBootstrapBtn" class="editor-bootstrap-btn">Create Workspace</button>' +
'<input type="text" id="editorBootstrapName" class="ext-editor-bootstrap-input" placeholder="Workspace name" value="workspace">' +
'<button id="editorBootstrapBtn" class="ext-editor-bootstrap-btn">Create Workspace</button>' +
'</div>';
return el;
}
@@ -218,7 +218,7 @@
}
workspaces.forEach(ws => {
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.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
listEl.appendChild(item);
@@ -243,10 +243,10 @@
listEl.innerHTML = '';
workspaces.forEach(ws => {
const item = document.createElement('button');
item.className = 'editor-bootstrap-ws-item';
item.className = 'ext-editor-bootstrap-ws-item';
item.innerHTML =
'<span class="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-name">' + esc(ws.name || ws.id?.slice(0, 8)) + '</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; });
listEl.appendChild(item);
});

View File

@@ -1,6 +1,6 @@
/* Git Board — Surface Styles */
.gb-shell {
.ext-git-board-shell {
display: flex;
flex-direction: column;
height: 100%;
@@ -9,80 +9,80 @@
/* ── Header ──────────────────────────────── */
.gb-header {
.ext-git-board-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.gb-header__left,
.gb-header__right {
.ext-git-board-header__left,
.ext-git-board-header__right {
display: flex;
align-items: center;
gap: 10px;
gap: var(--sp-3);
}
.gb-title {
.ext-git-board-title {
font-size: 18px;
font-weight: 600;
color: var(--text);
margin: 0;
}
.gb-repo-picker {
.ext-git-board-repo-picker {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--mono);
font-size: 13px;
padding: 5px 8px;
padding: 5px var(--sp-2);
max-width: 260px;
}
/* ── Connection Setup ─────────────────────── */
.gb-setup {
.ext-git-board-setup {
max-width: 480px;
margin: 60px auto;
text-align: center;
padding: 0 16px;
padding: 0 var(--sp-4);
}
.gb-setup h2 {
.ext-git-board-setup h2 {
color: var(--text);
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);
font-size: 14px;
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;
color: var(--text-3);
}
/* ── Kanban Board ────────────────────────── */
.gb-board {
.ext-git-board-board {
display: flex;
gap: 12px;
padding: 12px 16px;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
flex: 1;
overflow-x: auto;
overflow-y: hidden;
}
.gb-column {
.ext-git-board-column {
min-width: 260px;
max-width: 320px;
flex: 1;
@@ -94,15 +94,15 @@
overflow: hidden;
}
.gb-column__header {
.ext-git-board-column__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
padding: var(--sp-3) var(--sp-3);
border-bottom: 1px solid var(--border);
}
.gb-column__title {
.ext-git-board-column__title {
font-size: 13px;
font-weight: 600;
color: var(--text);
@@ -110,67 +110,67 @@
letter-spacing: 0.03em;
}
.gb-column__count {
.ext-git-board-column__count {
background: var(--bg-raised);
color: var(--text-2);
font-size: 11px;
font-weight: 600;
padding: 2px 7px;
border-radius: 10px;
border-radius: var(--radius-lg);
}
.gb-column__cards {
.ext-git-board-column__cards {
flex: 1;
overflow-y: auto;
padding: 8px;
padding: var(--sp-2);
display: flex;
flex-direction: column;
gap: 6px;
gap: var(--sp-2);
}
/* ── Cards ───────────────────────────────── */
.gb-card {
.ext-git-board-card {
display: block;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px;
padding: var(--sp-3);
text-decoration: none;
color: var(--text);
transition: border-color var(--transition), background var(--transition);
cursor: pointer;
}
.gb-card:hover {
.ext-git-board-card:hover {
border-color: var(--accent);
background: var(--bg-hover);
}
.gb-card--pr {
.ext-git-board-card--pr {
border-left: 3px solid var(--accent);
}
.gb-card__header {
.ext-git-board-card__header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
gap: var(--sp-2);
margin-bottom: var(--sp-1);
}
.gb-card__number {
.ext-git-board-card__number {
font-family: var(--mono);
font-size: 12px;
color: var(--text-3);
}
.gb-card__assignee {
.ext-git-board-card__assignee {
font-size: 11px;
color: var(--accent);
margin-left: auto;
}
.gb-card__branch {
.ext-git-board-card__branch {
font-family: var(--mono);
font-size: 11px;
color: var(--text-3);
@@ -181,54 +181,54 @@
max-width: 150px;
}
.gb-card__title {
.ext-git-board-card__title {
font-size: 13px;
font-weight: 500;
line-height: 1.4;
color: var(--text);
}
.gb-card__labels {
.ext-git-board-card__labels {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
gap: var(--sp-1);
margin-top: var(--sp-2);
}
.gb-card__labels .badge {
.ext-git-board-card__labels .ext-git-board-badge {
font-size: 10px;
padding: 1px 6px;
padding: 1px var(--sp-2);
}
.gb-card__meta {
.ext-git-board-card__meta {
display: flex;
justify-content: space-between;
margin-top: 6px;
margin-top: var(--sp-2);
font-size: 11px;
color: var(--text-3);
}
/* ── DnD States ─────────────────────────── */
.gb-card[draggable="true"] {
.ext-git-board-card[draggable="true"] {
cursor: grab;
user-select: none;
}
.gb-card[draggable="true"]:active {
.ext-git-board-card[draggable="true"]:active {
cursor: grabbing;
opacity: 0.6;
}
.gb-column--dragover {
.ext-git-board-column--dragover {
border-color: var(--accent);
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);
}
/* ── Empty state ─────────────────────────── */
.gb-empty {
.ext-git-board-empty {
display: flex;
align-items: center;
justify-content: center;
@@ -239,7 +239,7 @@
/* ── Issue Detail Modal ─────────────────── */
.gb-modal-overlay {
.ext-git-board-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
@@ -247,40 +247,41 @@
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px 16px;
padding: var(--sp-10) var(--sp-4);
overflow-y: auto;
}
.gb-modal {
.ext-git-board-modal {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
width: 100%;
max-width: 680px;
max-height: calc(100vh - 80px);
max-height: calc(100dvh - 80px);
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.gb-modal__header {
.ext-git-board-modal__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 20px;
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.gb-modal__title-row {
.ext-git-board-modal__title-row {
display: flex;
align-items: baseline;
gap: 8px;
gap: var(--sp-2);
flex: 1;
min-width: 0;
}
.gb-modal__title {
.ext-git-board-modal__title {
font-size: 18px;
font-weight: 600;
color: var(--text);
@@ -288,59 +289,59 @@
word-break: break-word;
}
.gb-modal__close {
.ext-git-board-modal__close {
background: none;
border: none;
color: var(--text-3);
font-size: 18px;
cursor: pointer;
padding: 2px 6px;
padding: 2px var(--sp-2);
border-radius: var(--radius);
flex-shrink: 0;
}
.gb-modal__close:hover {
.ext-git-board-modal__close:hover {
color: var(--text);
background: var(--bg-hover);
}
.gb-modal__body {
.ext-git-board-modal__body {
overflow-y: auto;
padding: 16px 20px;
padding: var(--sp-4) var(--sp-5);
flex: 1;
min-height: 0;
}
.gb-modal__meta {
.ext-git-board-modal__meta {
display: flex;
align-items: center;
gap: 10px;
gap: var(--sp-3);
flex-wrap: wrap;
margin-bottom: 12px;
margin-bottom: var(--sp-3);
font-size: 12px;
color: var(--text-2);
}
.gb-modal__date {
.ext-git-board-modal__date {
color: var(--text-3);
}
.gb-modal__extlink {
.ext-git-board-modal__extlink {
margin-left: auto;
color: var(--accent);
text-decoration: none;
font-size: 12px;
}
.gb-modal__extlink:hover {
.ext-git-board-modal__extlink:hover {
text-decoration: underline;
}
.gb-modal__description {
margin-bottom: 20px;
padding-bottom: 16px;
.ext-git-board-modal__description {
margin-bottom: var(--sp-5);
padding-bottom: var(--sp-4);
border-bottom: 1px solid var(--border);
}
.gb-modal__body-text {
.ext-git-board-modal__body-text {
font-family: var(--font);
font-size: 13px;
line-height: 1.6;
@@ -353,49 +354,49 @@
padding: 0;
}
.gb-modal__empty {
.ext-git-board-modal__empty {
color: var(--text-3);
font-size: 13px;
font-style: italic;
margin: 0;
}
.gb-modal__section-title {
.ext-git-board-modal__section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-2);
text-transform: uppercase;
letter-spacing: 0.03em;
margin: 0 0 12px;
margin: 0 0 var(--sp-3);
}
/* ── Comments ────────────────────────────── */
.gb-comment {
padding: 10px 0;
.ext-git-board-comment {
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border);
}
.gb-comment:last-child {
.ext-git-board-comment:last-child {
border-bottom: none;
}
.gb-comment__header {
.ext-git-board-comment__header {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 4px;
gap: var(--sp-2);
margin-bottom: var(--sp-1);
font-size: 12px;
}
.gb-comment__header strong {
.ext-git-board-comment__header strong {
color: var(--accent);
}
.gb-comment__date {
.ext-git-board-comment__date {
color: var(--text-3);
font-size: 11px;
}
.gb-comment__body {
.ext-git-board-comment__body {
font-size: 13px;
line-height: 1.5;
color: var(--text);
@@ -405,13 +406,13 @@
/* ── Add Comment ─────────────────────────── */
.gb-modal__add-comment {
margin-top: 16px;
padding-top: 16px;
.ext-git-board-modal__add-comment {
margin-top: var(--sp-4);
padding-top: var(--sp-4);
border-top: 1px solid var(--border);
}
.gb-modal__textarea {
.ext-git-board-modal__textarea {
width: 100%;
background: var(--bg-raised);
border: 1px solid var(--border);
@@ -419,38 +420,31 @@
color: var(--text);
font-family: var(--font);
font-size: 13px;
padding: 8px 10px;
padding: var(--sp-2) var(--sp-3);
resize: vertical;
outline: none;
box-sizing: border-box;
}
.gb-modal__textarea:focus {
.ext-git-board-modal__textarea:focus {
border-color: var(--accent);
}
.gb-modal__actions {
.ext-git-board-modal__actions {
display: flex;
gap: 8px;
margin-top: 8px;
gap: var(--sp-2);
margin-top: var(--sp-2);
justify-content: flex-end;
}
/* ── Badge variants ──────────────────────── */
.badge--green {
.ext-git-board-badge--green {
background: var(--green);
color: #fff;
}
.badge--muted {
.ext-git-board-badge--muted {
background: var(--bg-raised);
color: var(--text-3);
}
.btn-danger {
background: var(--danger, #e53e3e);
color: #fff;
border: none;
}
.btn-danger:hover {
opacity: 0.9;
}
/* git-board danger button override — uses kernel .sw-btn--danger */

View File

@@ -160,21 +160,21 @@
return html`
<div class="user-menu-container" ref=${menuRef}></div>
${needsConn ? html`<${ConnectionSetup} />` : html`
<div class="gb-shell">
<header class="gb-header">
<div class="gb-header__left">
<h1 class="gb-title">Git Board</h1>
<div class="ext-git-board-shell">
<header class="ext-git-board-header">
<div class="ext-git-board-header__left">
<h1 class="ext-git-board-title">Git Board</h1>
<${RepoPicker} repos=${repos} owner=${owner} repo=${repo}
onSelect=${function (o, r) { setOwner(o); setRepo(r); }} />
</div>
<div class="gb-header__right">
<button class="btn-small" onClick=${loadBoard} disabled=${loading}>
<div class="ext-git-board-header__right">
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${loadBoard} disabled=${loading}>
${loading ? '↻' : 'Refresh'}
</button>
</div>
</header>
${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>
`}
@@ -186,21 +186,21 @@
function ConnectionSetup() {
return html`
<div class="gb-shell">
<header class="gb-header">
<div class="gb-header__left"><h1 class="gb-title">Git Board</h1></div>
<div class="ext-git-board-shell">
<header class="ext-git-board-header">
<div class="ext-git-board-header__left"><h1 class="ext-git-board-title">Git Board</h1></div>
</header>
<div class="gb-setup">
<div class="ext-git-board-setup">
<h2>Connect to Gitea</h2>
<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
<strong>Admin → Connections</strong>, or add a personal one in
<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'; }}>
Open Settings
</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>
`;
@@ -211,7 +211,7 @@
function RepoPicker({ repos, owner, repo, onSelect }) {
var current = owner + '/' + repo;
return html`
<select class="gb-repo-picker" value=${current}
<select class="ext-git-board-repo-picker" value=${current}
onChange=${function (e) {
var parts = e.target.value.split('/');
onSelect(parts[0], parts.slice(1).join('/'));
@@ -233,7 +233,7 @@
var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; });
return html`
<div class="gb-board">
<div class="ext-git-board-board">
<${Column} id="open" title="Open" count=${openUnassigned.length} onDrop=${onDrop}>
${openUnassigned.map(function (i) {
return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
@@ -268,19 +268,19 @@
} : {};
return html`
<div class="gb-column ${over ? 'gb-column--dragover' : ''}" ...${handlers}>
<div class="gb-column__header">
<span class="gb-column__title">${title}</span>
<span class="gb-column__count">${count || 0}</span>
<div class="ext-git-board-column ${over ? 'ext-git-board-column--dragover' : ''}" ...${handlers}>
<div class="ext-git-board-column__header">
<span class="ext-git-board-column__title">${title}</span>
<span class="ext-git-board-column__count">${count || 0}</span>
</div>
<div class="gb-column__cards">${children}</div>
<div class="ext-git-board-column__cards">${children}</div>
</div>
`;
}
function IssueCard({ issue, onClick }) {
return html`
<div class="gb-card" draggable="true"
<div class="ext-git-board-card" draggable="true"
onDragStart=${function (e) {
e.dataTransfer.setData('text/plain', String(issue.number));
e.dataTransfer.effectAllowed = 'move';
@@ -289,14 +289,14 @@
e.preventDefault();
if (onClick) onClick(issue.number);
}}>
<div class="gb-card__header">
<span class="gb-card__number">#${issue.number}</span>
${issue.assignee && html`<span class="gb-card__assignee">@${esc(issue.assignee)}</span>`}
<div class="ext-git-board-card__header">
<span class="ext-git-board-card__number">#${issue.number}</span>
${issue.assignee && html`<span class="ext-git-board-card__assignee">@${esc(issue.assignee)}</span>`}
</div>
<div class="gb-card__title">${esc(issue.title)}</div>
<div class="gb-card__labels">
<div class="ext-git-board-card__title">${esc(issue.title)}</div>
<div class="ext-git-board-card__labels">
${(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>
@@ -305,13 +305,13 @@
function PRCard({ pr }) {
return html`
<a class="gb-card gb-card--pr" href=${pr.html_url} target="_blank" rel="noopener">
<div class="gb-card__header">
<span class="gb-card__number">#${pr.number}</span>
<span class="gb-card__branch">${esc(pr.head)}${esc(pr.base)}</span>
<a class="ext-git-board-card ext-git-board-card--pr" href=${pr.html_url} target="_blank" rel="noopener">
<div class="ext-git-board-card__header">
<span class="ext-git-board-card__number">#${pr.number}</span>
<span class="ext-git-board-card__branch">${esc(pr.head)}${esc(pr.base)}</span>
</div>
<div class="gb-card__title">${esc(pr.title)}</div>
<div class="gb-card__meta">
<div class="ext-git-board-card__title">${esc(pr.title)}</div>
<div class="ext-git-board-card__meta">
<span>@${esc(pr.user)}</span>
<span>${timeAgo(pr.created_at)}</span>
</div>
@@ -387,73 +387,73 @@
}, [issue, owner, repo, number]);
return html`
<div class="gb-modal-overlay" onClick=${function (e) {
if (e.target.classList.contains('gb-modal-overlay')) onClose(changed);
<div class="ext-git-board-modal-overlay" onClick=${function (e) {
if (e.target.classList.contains('ext-git-board-modal-overlay')) onClose(changed);
}}>
<div class="gb-modal">
<div class="gb-modal__header">
<div class="gb-modal__title-row">
<div class="ext-git-board-modal">
<div class="ext-git-board-modal__header">
<div class="ext-git-board-modal__title-row">
${loading ? 'Loading…' : html`
<span class="gb-card__number">#${number}</span>
<h2 class="gb-modal__title">${esc(issue && issue.title)}</h2>
<span class="ext-git-board-card__number">#${number}</span>
<h2 class="ext-git-board-modal__title">${esc(issue && issue.title)}</h2>
`}
</div>
<button class="gb-modal__close" onClick=${function () { onClose(changed); }}>✕</button>
<button class="ext-git-board-modal__close" onClick=${function () { onClose(changed); }}>✕</button>
</div>
${!loading && issue && html`
<div class="gb-modal__body" ref=${bodyRef}>
<div class="gb-modal__meta">
<span class="badge ${issue.state === 'open' ? 'badge--green' : 'badge--muted'}">${issue.state}</span>
${issue.assignee && html`<span class="gb-card__assignee">@${esc(issue.assignee)}</span>`}
${issue.created_at && html`<span class="gb-modal__date">${new Date(issue.created_at).toLocaleDateString()}</span>`}
<div class="ext-git-board-modal__body" ref=${bodyRef}>
<div class="ext-git-board-modal__meta">
<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="ext-git-board-card__assignee">@${esc(issue.assignee)}</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"
class="gb-modal__extlink">Open in Gitea ↗</a>
class="ext-git-board-modal__extlink">Open in Gitea ↗</a>
</div>
${issue.labels && issue.labels.length > 0 && html`
<div class="gb-card__labels" style="margin-bottom:12px;">
${issue.labels.map(function (l) { return html`<span key=${l} class="badge">${esc(l)}</span>`; })}
<div class="ext-git-board-card__labels" style="margin-bottom:12px;">
${issue.labels.map(function (l) { return html`<span key=${l} class="ext-git-board-badge">${esc(l)}</span>`; })}
</div>
`}
<div class="gb-modal__description">
${issue.body ? html`<pre class="gb-modal__body-text">${esc(issue.body)}</pre>`
: html`<p class="gb-modal__empty">No description.</p>`}
<div class="ext-git-board-modal__description">
${issue.body ? html`<pre class="ext-git-board-modal__body-text">${esc(issue.body)}</pre>`
: html`<p class="ext-git-board-modal__empty">No description.</p>`}
</div>
<div class="gb-modal__comments">
<h3 class="gb-modal__section-title">Comments (${(issue.comments || []).length})</h3>
<div class="ext-git-board-modal__comments">
<h3 class="ext-git-board-modal__section-title">Comments (${(issue.comments || []).length})</h3>
${(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) {
return html`
<div class="gb-comment" key=${i}>
<div class="gb-comment__header">
<div class="ext-git-board-comment" key=${i}>
<div class="ext-git-board-comment__header">
<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 class="gb-comment__body">${esc(c.body)}</div>
<div class="ext-git-board-comment__body">${esc(c.body)}</div>
</div>
`;
})}
</div>
<div class="gb-modal__add-comment">
<textarea class="gb-modal__textarea" rows="3"
<div class="ext-git-board-modal__add-comment">
<textarea class="ext-git-board-modal__textarea" rows="3"
placeholder="Add a comment…"
value=${comment}
onInput=${function (e) { setComment(e.target.value); }}
onKeyDown=${function (e) {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) postComment();
}} />
<div class="gb-modal__actions">
<button class="btn-small btn-primary" disabled=${posting || !comment.trim()}
<div class="ext-git-board-modal__actions">
<button class="sw-btn sw-btn--primary sw-btn--sm" disabled=${posting || !comment.trim()}
onClick=${postComment}>
${posting ? 'Posting…' : 'Comment'}
</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}>
${issue.state === 'open' ? 'Close Issue' : 'Reopen Issue'}
</button>

View File

@@ -2,16 +2,16 @@
Uses CSS custom properties from the platform theme system (variables.css).
See EXTENSION-SURFACES.md for the full property reference. */
.hello-dashboard { max-width: 720px; margin: 0 auto; padding: 40px 24px; }
.hello-header { margin-bottom: 32px; }
.hello-header h1 { font-size: 28px; font-weight: 700; color: var(--text); margin: 0 0 8px 0; }
.hello-subtitle { font-size: 14px; color: var(--text-2); margin: 0; }
.hello-subtitle code,
.hello-card-value code { background: var(--bg-raised); padding: 2px 6px; border-radius: 4px; font-size: 13px; }
.hello-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
.hello-card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 16px; }
.hello-card-title { font-size: 12px; font-weight: 600; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.hello-card-value { font-size: 20px; font-weight: 600; color: var(--text); margin-bottom: 4px; }
.hello-card-detail { font-size: 12px; color: var(--text-3); }
.hello-actions { display: flex; gap: 12px; margin-bottom: 24px; }
.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 { max-width: 720px; margin: 0 auto; padding: var(--sp-10) var(--sp-6); }
.ext-hello-dashboard-header { margin-bottom: var(--sp-8); }
.ext-hello-dashboard-header h1 { font-size: 28px; font-weight: 700; color: var(--text); margin: 0 0 var(--sp-2) 0; }
.ext-hello-dashboard-subtitle { font-size: 14px; color: var(--text-2); margin: 0; }
.ext-hello-dashboard-subtitle code,
.ext-hello-dashboard-card-value code { background: var(--bg-raised); padding: 2px 6px; border-radius: var(--radius-sm); font-size: 13px; }
.ext-hello-dashboard-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--sp-4); margin-bottom: var(--sp-6); }
.ext-hello-dashboard-card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: var(--sp-4); }
.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); }
.ext-hello-dashboard-card-value { font-size: 20px; font-weight: 600; color: var(--text); margin-bottom: var(--sp-1); }
.ext-hello-dashboard-card-detail { font-size: 12px; color: var(--text-3); }
.ext-hello-dashboard-actions { display: flex; gap: var(--sp-3); margin-bottom: var(--sp-6); }
.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; }

View File

@@ -20,12 +20,12 @@
var name = user.display_name || user.username || 'World';
mount.innerHTML =
'<div class="hello-dashboard">' +
'<div class="hello-header">' +
'<div class="ext-hello-dashboard">' +
'<div class="ext-hello-dashboard-header">' +
'<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 class="hello-cards">' +
'<div class="ext-hello-dashboard-cards">' +
card('Platform Access', (typeof UI !== 'undefined' ? '\u2713 Connected' : '\u2717 Unavailable'),
'API, Theme, UI primitives available', 'var(--accent)') +
card('Theme', isDark ? '\uD83C\uDF19 Dark' : '\u2600\uFE0F Light',
@@ -33,11 +33,11 @@
card('Route', '<code>' + esc(manifest.route || '/s/hello-dashboard') + '</code>',
'Registered via surface manifest', '') +
'</div>' +
'<div class="hello-actions">' +
'<button class="btn-primary" id="helloToast">Show Toast</button>' +
'<button class="btn-secondary" id="helloApi">Test API</button>' +
'<div class="ext-hello-dashboard-actions">' +
'<button class="sw-btn sw-btn--primary sw-btn--md" id="helloToast">Show Toast</button>' +
'<button class="sw-btn sw-btn--secondary sw-btn--md" id="helloApi">Test API</button>' +
'</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>';
// Wire toast button
@@ -69,10 +69,10 @@
function card(title, value, detail, color) {
var style = color ? ' style="color:' + color + ';"' : '';
return '<div class="hello-card">' +
'<div class="hello-card-title">' + esc(title) + '</div>' +
'<div class="hello-card-value"' + style + '>' + value + '</div>' +
'<div class="hello-card-detail">' + esc(detail) + '</div>' +
return '<div class="ext-hello-dashboard-card">' +
'<div class="ext-hello-dashboard-card-title">' + esc(title) + '</div>' +
'<div class="ext-hello-dashboard-card-value"' + style + '>' + value + '</div>' +
'<div class="ext-hello-dashboard-card-detail">' + esc(detail) + '</div>' +
'</div>';
}

View File

@@ -1,74 +1,38 @@
/* ICD Test Runner — Surface Styles */
#extension-mount {
.ext-icd-test-runner-root {
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);
letter-spacing: -0.3px;
}
#extension-mount table {
.ext-icd-test-runner-root table {
font-variant-numeric: tabular-nums;
}
#extension-mount table td,
#extension-mount table th {
.ext-icd-test-runner-root table td,
.ext-icd-test-runner-root table th {
border-color: var(--border);
}
#extension-mount table tbody tr:last-child {
.ext-icd-test-runner-root table tbody tr:last-child {
border-bottom: none;
}
#extension-mount table tbody tr:hover {
.ext-icd-test-runner-root table tbody tr:hover {
background: var(--bg-hover) !important;
}
/* Buttons — override small padding for our use */
#extension-mount .btn-primary,
#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);
/* Buttons — uses kernel sw-btn system, minor overrides for weight */
.ext-icd-test-runner-root .sw-btn {
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 */
@keyframes pulse-dot {
@keyframes ext-icd-test-runner-pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}

View File

@@ -24,6 +24,7 @@
var mount = document.getElementById('extension-mount');
if (!mount) return;
mount.classList.add('ext-icd-test-runner-root');
// ─── Boot Preact SDK (v0.37.14) ───────────────────────────
// Extension surfaces don't load Preact vendors or the SDK.

View File

@@ -337,7 +337,7 @@
await T.test('sdk', 'pipe-render', 'Render filter modifies DOM', async function () {
sw.pipe.render(1, function (ctx) {
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;
}, { source: '_test-render-dom' });
@@ -348,7 +348,7 @@
element: el, message: null,
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 () {

View File

@@ -93,7 +93,7 @@
// Configure 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' },
onClick: function () {
if (!T.providerSetup.apiKey) {
@@ -110,7 +110,7 @@
if (T.providerSetup.configured) {
var clearBtn = $('button', {
className: 'btn-ghost',
className: 'sw-btn sw-btn--ghost sw-btn--sm',
style: { alignSelf: 'flex-end', color: 'var(--danger)', whiteSpace: 'nowrap' },
onClick: function () {
T.providerSetup.apiKey = '';
@@ -189,19 +189,19 @@
T.renderControls = function () {
T.el.controls.innerHTML = '';
var btnSmoke = $('button', {
className: 'btn-primary',
className: 'sw-btn sw-btn--primary sw-btn--md',
onClick: function () { T.runSuite('smoke'); }
}, 'Smoke');
var btnCrud = $('button', {
className: 'btn-secondary',
className: 'sw-btn sw-btn--secondary sw-btn--md',
onClick: function () { T.runSuite('crud'); }
}, 'CRUD');
var btnAll = $('button', {
className: 'btn-primary',
className: 'sw-btn sw-btn--primary sw-btn--md',
onClick: function () { T.runSuite('all'); }
}, 'Run All');
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(); }
}, 'Clear');
T.el.controls.appendChild(btnSmoke);
@@ -210,14 +210,14 @@
// AuthZ button — only useful if admin (needs fixtures)
if (T.user.role === 'admin') {
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' },
onClick: function () { T.runSuite('authz'); }
}, 'AuthZ');
T.el.controls.appendChild(btnAuthz);
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: {
opacity: T.fixtures.ready ? '1' : '0.5',
borderColor: T.fixtures.ready ? 'var(--danger)' : undefined,
@@ -230,7 +230,7 @@
// Provider tier button — needs provider setup
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' },
onClick: function () { T.runSuite('provider'); }
}, 'Providers');
@@ -238,7 +238,7 @@
// SDK tier button — tests armature-sdk.js contract
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' },
onClick: function () { T.runSuite('sdk'); }
}, 'SDK');
@@ -246,7 +246,7 @@
// Packaging tier button — extension permission lifecycle (v0.29.0)
var btnPkg = $('button', {
className: 'btn-secondary',
className: 'sw-btn sw-btn--secondary sw-btn--md',
onClick: function () { T.runSuite('packaging'); }
}, 'Packaging');
T.el.controls.appendChild(btnPkg);
@@ -259,11 +259,11 @@
// Export buttons
var btnCopy = $('button', {
className: 'btn-ghost',
className: 'sw-btn sw-btn--ghost sw-btn--sm',
onClick: function () { T.exportReport('clipboard'); }
}, 'Copy Report');
var btnDownload = $('button', {
className: 'btn-ghost',
className: 'sw-btn sw-btn--ghost sw-btn--sm',
onClick: function () { T.exportReport('download'); }
}, 'Download');
T.el.controls.appendChild(btnCopy);
@@ -274,12 +274,12 @@
T.el.controls.appendChild($('div', { style: { width: '1px', height: '28px', background: 'var(--border)', flexShrink: '0' } }));
if (!T.fixtures.ready) {
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(); }
}, 'Provision Test Users'));
} else {
T.el.controls.appendChild($('button', {
className: 'btn-ghost',
className: 'sw-btn sw-btn--ghost sw-btn--sm',
style: { color: 'var(--danger)' },
onClick: async function () { await T.teardownFixtures(); T.renderControls(); }
}, 'Tear Down Fixtures'));

View File

@@ -1,13 +1,13 @@
/* ── Notes Surface Styles ─────────────────── */
.notes-app {
.ext-notes-app {
display: flex;
height: 100%;
overflow: hidden;
}
/* ── Sidebar ─────────────────────────────── */
.notes-sidebar {
.ext-notes-sidebar {
width: 280px;
min-width: 220px;
border-right: 1px solid var(--border);
@@ -16,106 +16,106 @@
background: var(--bg-raised);
flex-shrink: 0;
}
.notes-sidebar__header {
.ext-notes-sidebar__header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.notes-sidebar__title {
.ext-notes-sidebar__title {
font-size: 14px;
font-weight: 600;
color: var(--text);
flex: 1;
}
.notes-sidebar__count {
.ext-notes-sidebar__count {
font-size: 12px;
color: var(--text-3);
}
.notes-sidebar__actions {
.ext-notes-sidebar__actions {
display: flex;
gap: 4px;
gap: var(--sp-1);
}
/* ── Folder Tree ────────────────────────── */
.folder-tree {
padding: 4px 0;
.ext-notes-folder-tree {
padding: var(--sp-1) 0;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
max-height: 40%;
overflow-y: auto;
}
.folder-tree__item {
.ext-notes-folder-tree__item {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
gap: var(--sp-1);
padding: 5px var(--sp-3);
font-size: 13px;
color: var(--text-2);
cursor: pointer;
border-radius: var(--radius);
margin: 1px 6px;
margin: 1px var(--sp-2);
transition: var(--transition);
user-select: none;
}
.folder-tree__item:hover {
.ext-notes-folder-tree__item:hover {
background: var(--bg-hover);
color: var(--text);
}
.folder-tree__item--active {
.ext-notes-folder-tree__item--active {
background: var(--bg-active);
color: var(--text);
font-weight: 500;
}
.folder-tree__toggle {
.ext-notes-folder-tree__toggle {
width: 14px;
font-size: 9px;
text-align: center;
flex-shrink: 0;
color: var(--text-3);
}
.folder-tree__icon {
.ext-notes-folder-tree__icon {
font-size: 13px;
flex-shrink: 0;
}
.folder-tree__name {
.ext-notes-folder-tree__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.folder-tree__edit {
.ext-notes-folder-tree__edit {
flex: 1;
font-size: 13px;
padding: 1px 4px;
padding: 1px var(--sp-1);
border: 1px solid var(--accent);
border-radius: 3px;
border-radius: var(--radius-sm);
background: var(--bg-surface);
color: var(--text);
font-family: var(--font);
outline: none;
}
.folder-tree__add {
padding: 6px 12px;
.ext-notes-folder-tree__add {
padding: var(--sp-2) var(--sp-3);
font-size: 12px;
color: var(--text-3);
cursor: pointer;
transition: var(--transition);
}
.folder-tree__add:hover {
.ext-notes-folder-tree__add:hover {
color: var(--accent);
}
/* ── Search ──────────────────────────────── */
.notes-search {
padding: 8px 14px;
.ext-notes-search {
padding: var(--sp-2) var(--sp-4);
flex-shrink: 0;
}
.notes-search input {
.ext-notes-search input {
width: 100%;
padding: 6px 10px;
padding: var(--sp-2) var(--sp-3);
font-size: 13px;
background: var(--bg-surface);
color: var(--text);
@@ -123,63 +123,63 @@
border-radius: var(--radius);
font-family: var(--font);
}
.notes-search input:focus {
.ext-notes-search input:focus {
outline: none;
border-color: var(--accent);
}
.notes-search input::placeholder {
.ext-notes-search input::placeholder {
color: var(--text-3);
}
/* ── Note List ───────────────────────────── */
.notes-list {
.ext-notes-list {
flex: 1;
overflow-y: auto;
padding: 4px 8px;
padding: var(--sp-1) var(--sp-2);
}
.notes-list__empty {
padding: 40px 16px;
.ext-notes-list__empty {
padding: var(--sp-10) var(--sp-4);
text-align: center;
color: var(--text-3);
font-size: 13px;
}
.notes-list__loading {
padding: 40px 0;
.ext-notes-list__loading {
padding: var(--sp-10) 0;
display: flex;
justify-content: center;
color: var(--text-3);
}
/* ── Note Card (sidebar item) ────────────── */
.note-card {
padding: 10px 12px;
.ext-notes-note-card {
padding: var(--sp-3) var(--sp-3);
border-radius: var(--radius);
cursor: pointer;
transition: var(--transition);
margin-bottom: 2px;
}
.note-card:hover {
.ext-notes-note-card:hover {
background: var(--bg-hover);
}
.note-card--active {
.ext-notes-note-card--active {
background: var(--bg-active);
}
.note-card__title {
.ext-notes-note-card__title {
font-size: 13px;
font-weight: 500;
color: var(--text);
display: flex;
align-items: center;
gap: 6px;
gap: var(--sp-2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-card__pin {
.ext-notes-note-card__pin {
font-size: 11px;
flex-shrink: 0;
}
.note-card__snippet {
.ext-notes-note-card__snippet {
font-size: 12px;
color: var(--text-3);
margin-top: 3px;
@@ -188,21 +188,21 @@
white-space: nowrap;
line-height: 1.4;
}
.note-card__date {
.ext-notes-note-card__date {
font-size: 11px;
color: var(--text-3);
margin-top: 3px;
}
/* ── Editor Pane ─────────────────────────── */
.notes-editor {
.ext-notes-editor {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.notes-editor__empty {
.ext-notes-editor__empty {
flex: 1;
display: flex;
align-items: center;
@@ -212,15 +212,15 @@
}
/* ── Editor Header ───────────────────────── */
.notes-editor__header {
.ext-notes-editor__header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-5);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.notes-editor__title-input {
.ext-notes-editor__title-input {
flex: 1;
font-size: 18px;
font-weight: 600;
@@ -228,22 +228,22 @@
background: transparent;
border: none;
outline: none;
padding: 4px 0;
padding: var(--sp-1) 0;
font-family: var(--font);
}
.notes-editor__title-input::placeholder {
.ext-notes-editor__title-input::placeholder {
color: var(--text-3);
}
.notes-editor__actions {
.ext-notes-editor__actions {
display: flex;
gap: 6px;
gap: var(--sp-2);
align-items: center;
}
/* ── Folder select in editor ────────────── */
.notes-editor__folder-select {
.ext-notes-editor__folder-select {
font-size: 12px;
padding: 3px 6px;
padding: 3px var(--sp-2);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-raised);
@@ -252,13 +252,13 @@
cursor: pointer;
max-width: 140px;
}
.notes-editor__folder-select:focus {
.ext-notes-editor__folder-select:focus {
outline: none;
border-color: var(--accent);
}
/* ── Editor Content (body + outline wrapper) */
.notes-editor__content {
.ext-notes-editor__content {
flex: 1;
display: flex;
min-height: 0;
@@ -266,23 +266,23 @@
}
/* ── Editor Body ─────────────────────────── */
.notes-editor__body {
.ext-notes-editor__body {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
}
.notes-editor__body--split {
.ext-notes-editor__body--split {
display: grid;
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);
}
.notes-editor__textarea {
.ext-notes-editor__textarea {
flex: 1;
resize: none;
padding: 20px;
padding: var(--sp-5);
font-size: 14px;
line-height: 1.6;
color: var(--text);
@@ -292,79 +292,79 @@
font-family: var(--mono);
tab-size: 2;
}
.notes-editor__textarea::placeholder {
.ext-notes-editor__textarea::placeholder {
color: var(--text-3);
}
/* ── CodeMirror 6 container ─────────────── */
.notes-editor__cm {
.ext-notes-editor__cm {
flex: 1;
overflow: hidden;
}
.notes-editor__cm .cm-editor {
.ext-notes-editor__cm .cm-editor {
height: 100%;
max-height: none;
}
.notes-editor__cm .cm-scroller {
.ext-notes-editor__cm .cm-scroller {
overflow: auto;
padding: 12px 20px;
padding: var(--sp-3) var(--sp-5);
}
/* ── Preview ─────────────────────────────── */
.notes-preview {
.ext-notes-preview {
flex: 1;
overflow-y: auto;
padding: 20px;
padding: var(--sp-5);
border-left: 1px solid var(--border);
background: var(--bg-surface);
}
.notes-preview h1 { font-size: 24px; font-weight: 700; color: var(--text); margin: 0 0 12px; }
.notes-preview h2 { font-size: 20px; font-weight: 600; color: var(--text); margin: 20px 0 8px; }
.notes-preview h3 { font-size: 16px; font-weight: 600; color: var(--text); margin: 16px 0 6px; }
.notes-preview p { font-size: 14px; color: var(--text); line-height: 1.7; margin: 0 0 10px; }
.notes-preview ul, .notes-preview ol { padding-left: 24px; margin: 0 0 10px; color: var(--text); font-size: 14px; line-height: 1.7; }
.notes-preview li { margin-bottom: 2px; }
.notes-preview code {
.ext-notes-preview h1 { font-size: 24px; font-weight: 700; color: var(--text); margin: 0 0 var(--sp-3); }
.ext-notes-preview h2 { font-size: 20px; font-weight: 600; color: var(--text); margin: var(--sp-5) 0 var(--sp-2); }
.ext-notes-preview h3 { font-size: 16px; font-weight: 600; color: var(--text); margin: var(--sp-4) 0 var(--sp-2); }
.ext-notes-preview p { font-size: 14px; color: var(--text); line-height: 1.7; margin: 0 0 var(--sp-3); }
.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; }
.ext-notes-preview li { margin-bottom: 2px; }
.ext-notes-preview code {
font-family: var(--mono);
font-size: 13px;
background: var(--bg-raised);
padding: 2px 6px;
border-radius: 3px;
padding: 2px var(--sp-2);
border-radius: var(--radius-sm);
color: var(--accent);
}
.notes-preview pre {
.ext-notes-preview pre {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
padding: var(--sp-4);
overflow-x: auto;
margin: 0 0 12px;
margin: 0 0 var(--sp-3);
}
.notes-preview pre code {
.ext-notes-preview pre code {
background: none;
padding: 0;
color: var(--text);
font-size: 13px;
}
.notes-preview blockquote {
.ext-notes-preview blockquote {
border-left: 3px solid var(--accent);
margin: 0 0 12px;
padding: 4px 16px;
margin: 0 0 var(--sp-3);
padding: var(--sp-1) var(--sp-4);
color: var(--text-2);
}
.notes-preview a { color: var(--accent); text-decoration: none; }
.notes-preview a:hover { text-decoration: underline; }
.notes-preview hr {
.ext-notes-preview a { color: var(--accent); text-decoration: none; }
.ext-notes-preview a:hover { text-decoration: underline; }
.ext-notes-preview hr {
border: none;
border-top: 1px solid var(--border);
margin: 16px 0;
margin: var(--sp-4) 0;
}
.notes-preview strong { font-weight: 600; }
.notes-preview em { font-style: italic; }
.ext-notes-preview strong { font-weight: 600; }
.ext-notes-preview em { font-style: italic; }
/* ── Toggle button ───────────────────────── */
.notes-toggle {
padding: 4px 10px;
.ext-notes-toggle {
padding: var(--sp-1) var(--sp-3);
font-size: 12px;
border: 1px solid var(--border);
background: var(--bg-raised);
@@ -373,43 +373,43 @@
cursor: pointer;
transition: var(--transition);
}
.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:hover { background: var(--bg-hover); color: var(--text); }
.ext-notes-toggle--active { background: var(--accent); color: #fff; border-color: var(--accent); }
/* ── Inline buttons ──────────────────────── */
.notes-btn {
.ext-notes-btn {
border: none;
background: var(--bg-raised);
color: var(--text-2);
cursor: pointer;
border-radius: var(--radius);
transition: var(--transition);
padding: 4px 10px;
padding: var(--sp-1) var(--sp-3);
font-size: 13px;
}
.notes-btn:hover { background: var(--bg-hover); color: var(--text); }
.notes-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
.notes-btn--accent { background: var(--accent); color: #fff; }
.notes-btn--accent:hover { opacity: 0.9; color: #fff; }
.ext-notes-btn:hover { background: var(--bg-hover); color: var(--text); }
.ext-notes-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
.ext-notes-btn--accent { background: var(--accent); color: #fff; }
.ext-notes-btn--accent:hover { opacity: 0.9; color: #fff; }
/* ── Saved indicator ─────────────────────── */
.notes-saved {
.ext-notes-saved {
font-size: 12px;
color: var(--text-3);
padding: 2px 8px;
padding: 2px var(--sp-2);
}
.notes-saved--dirty {
.ext-notes-saved--dirty {
color: var(--warning);
}
/* ── Tag Pills (shared) ─────────────────── */
.tag-pill {
.ext-notes-tag-pill {
display: inline-block;
padding: 1px 8px;
padding: 1px var(--sp-2);
font-size: 11px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 10px;
border-radius: var(--radius-lg);
color: var(--text-2);
margin: 2px 2px;
white-space: nowrap;
@@ -417,23 +417,23 @@
line-height: 1.6;
transition: var(--transition);
}
.tag-pill:hover { background: var(--bg-hover); color: var(--text); }
.tag-pill--active { background: var(--accent); color: #fff; border-color: var(--accent); }
.tag-pill__remove {
margin-left: 4px;
.ext-notes-tag-pill:hover { background: var(--bg-hover); color: var(--text); }
.ext-notes-tag-pill--active { background: var(--accent); color: #fff; border-color: var(--accent); }
.ext-notes-tag-pill__remove {
margin-left: var(--sp-1);
font-size: 10px;
opacity: 0.6;
cursor: pointer;
}
.tag-pill__remove:hover { opacity: 1; }
.ext-notes-tag-pill__remove:hover { opacity: 1; }
/* ── Tag Filter (sidebar) ──────────────── */
.tag-filter {
padding: 6px 14px 4px;
.ext-notes-tag-filter {
padding: var(--sp-2) var(--sp-4) var(--sp-1);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.tag-filter__label {
.ext-notes-tag-filter__label {
font-size: 11px;
color: var(--text-3);
margin-bottom: 3px;
@@ -441,7 +441,7 @@
align-items: center;
justify-content: space-between;
}
.tag-filter__clear {
.ext-notes-tag-filter__clear {
font-size: 10px;
color: var(--accent);
cursor: pointer;
@@ -450,27 +450,27 @@
padding: 0;
font-family: var(--font);
}
.tag-filter__clear:hover { text-decoration: underline; }
.tag-filter__pills {
.ext-notes-tag-filter__clear:hover { text-decoration: underline; }
.ext-notes-tag-filter__pills {
display: flex;
flex-wrap: wrap;
gap: 3px;
max-height: 60px;
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 {
.ext-notes-tag-input {
display: flex;
flex-wrap: wrap;
gap: 4px;
gap: var(--sp-1);
align-items: center;
padding: 4px 20px 6px;
padding: var(--sp-1) var(--sp-5) var(--sp-2);
border-bottom: 1px solid var(--border);
position: relative;
}
.tag-input__field {
.ext-notes-tag-input__field {
border: none;
outline: none;
font-size: 12px;
@@ -481,14 +481,14 @@
padding: 2px 0;
font-family: var(--font);
}
.tag-input__field::placeholder { color: var(--text-3); }
.tag-input .tag-pill { font-size: 11px; }
.ext-notes-tag-input__field::placeholder { color: var(--text-3); }
.ext-notes-tag-input .ext-notes-tag-pill { font-size: 11px; }
/* ── Tag Autocomplete ──────────────────── */
.tag-autocomplete {
.ext-notes-tag-autocomplete {
position: absolute;
top: 100%;
left: 20px;
left: var(--sp-5);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
@@ -498,23 +498,23 @@
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
min-width: 140px;
}
.tag-autocomplete__item {
padding: 4px 10px;
.ext-notes-tag-autocomplete__item {
padding: var(--sp-1) var(--sp-3);
font-size: 12px;
cursor: pointer;
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 {
.ext-notes-note-card__tags {
display: flex;
gap: 3px;
margin-top: 3px;
overflow: hidden;
}
.note-card__tags .tag-pill { font-size: 10px; padding: 0 6px; cursor: default; }
.note-card__tags .tag-pill--overflow {
.ext-notes-note-card__tags .ext-notes-tag-pill { font-size: 10px; padding: 0 var(--sp-2); cursor: default; }
.ext-notes-note-card__tags .ext-notes-tag-pill--overflow {
background: transparent;
border: none;
color: var(--text-3);
@@ -523,7 +523,7 @@
}
/* ── Folder Context Menu ───────────────── */
.folder-context-menu {
.ext-notes-folder-context-menu {
position: fixed;
z-index: 100;
background: var(--bg-surface);
@@ -531,116 +531,116 @@
border-radius: var(--radius);
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
min-width: 150px;
padding: 4px 0;
padding: var(--sp-1) 0;
}
.folder-context-menu__item {
padding: 6px 14px;
.ext-notes-folder-context-menu__item {
padding: var(--sp-2) var(--sp-4);
font-size: 13px;
color: var(--text);
cursor: pointer;
transition: var(--transition);
}
.folder-context-menu__item:hover {
.ext-notes-folder-context-menu__item:hover {
background: var(--bg-hover);
}
.folder-context-menu__item--danger {
.ext-notes-folder-context-menu__item--danger {
color: var(--danger);
}
.folder-context-menu__item--danger:hover {
.ext-notes-folder-context-menu__item--danger:hover {
background: var(--danger-dim);
}
/* ── Drag and Drop ─────────────────────── */
.note-card--dragging { opacity: 0.4; }
.folder-tree__item--drop-target {
.ext-notes-note-card--dragging { opacity: 0.4; }
.ext-notes-folder-tree__item--drop-target {
background: color-mix(in srgb, var(--accent) 10%, transparent) !important;
outline: 2px dashed var(--accent);
outline-offset: -2px;
}
/* ── Wikilinks ──────────────────────────── */
.wikilink {
.ext-notes-wikilink {
color: var(--accent);
text-decoration: none;
border-bottom: 1px dashed var(--accent);
cursor: pointer;
}
.wikilink:hover {
.ext-notes-wikilink:hover {
border-bottom-style: solid;
}
.wikilink.wikilink--unresolved {
color: var(--danger, #e53e3e);
border-bottom-color: var(--danger, #e53e3e);
.ext-notes-wikilink.ext-notes-wikilink--unresolved {
color: var(--danger);
border-bottom-color: var(--danger);
opacity: 0.7;
}
.wikilink.wikilink--unresolved:hover {
.ext-notes-wikilink.ext-notes-wikilink--unresolved:hover {
opacity: 1;
}
/* ── Backlinks Panel ────────────────────── */
.backlinks-panel {
.ext-notes-backlinks-panel {
border-top: 1px solid var(--border);
padding: 10px 16px;
padding: var(--sp-3) var(--sp-4);
background: var(--bg-raised);
flex-shrink: 0;
max-height: 200px;
overflow-y: auto;
}
.backlinks-panel__header {
.ext-notes-backlinks-panel__header {
font-size: 11px;
font-weight: 600;
color: var(--text-2);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
margin-bottom: var(--sp-2);
display: flex;
align-items: center;
gap: 6px;
gap: var(--sp-2);
cursor: pointer;
user-select: none;
}
.backlinks-panel__toggle {
.ext-notes-backlinks-panel__toggle {
font-size: 9px;
color: var(--text-2);
}
.backlinks-panel__count {
.ext-notes-backlinks-panel__count {
background: var(--bg-hover);
color: var(--text-2);
font-size: 10px;
padding: 0 6px;
border-radius: 8px;
padding: 0 var(--sp-2);
border-radius: var(--radius);
line-height: 16px;
}
.backlinks-panel__item {
.ext-notes-backlinks-panel__item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 4px;
gap: var(--sp-2);
padding: var(--sp-1) var(--sp-2);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
}
.backlinks-panel__item:hover {
.ext-notes-backlinks-panel__item:hover {
background: var(--bg-hover);
}
.backlinks-panel__title {
.ext-notes-backlinks-panel__title {
color: var(--accent);
font-weight: 500;
}
.backlinks-panel__context {
.ext-notes-backlinks-panel__context {
color: var(--text-2);
font-size: 11px;
}
/* ── Sidebar Tabs ────────────────────────── */
.sidebar-tabs {
.ext-notes-sidebar-tabs {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.sidebar-tabs__tab {
.ext-notes-sidebar-tabs__tab {
flex: 1;
padding: 10px 14px;
padding: var(--sp-3) var(--sp-4);
font-size: 13px;
font-weight: 500;
color: var(--text-2);
@@ -652,38 +652,38 @@
text-align: center;
font-family: var(--font);
}
.sidebar-tabs__tab:hover {
.ext-notes-sidebar-tabs__tab:hover {
color: var(--text);
background: var(--bg-hover);
}
.sidebar-tabs__tab--active {
.ext-notes-sidebar-tabs__tab--active {
color: var(--text);
border-bottom-color: var(--accent);
}
.sidebar-tabs__tab--disabled {
.ext-notes-sidebar-tabs__tab--disabled {
color: var(--text-3);
cursor: default;
opacity: 0.5;
}
.sidebar-tabs__tab--disabled:hover {
.ext-notes-sidebar-tabs__tab--disabled:hover {
background: none;
color: var(--text-3);
}
/* ── Sidebar Outline ─────────────────────── */
.sidebar-outline {
.ext-notes-sidebar-outline {
flex: 1;
overflow-y: auto;
padding: 8px 0;
padding: var(--sp-2) 0;
}
.sidebar-outline__empty {
padding: 40px 16px;
.ext-notes-sidebar-outline__empty {
padding: var(--sp-10) var(--sp-4);
text-align: center;
color: var(--text-3);
font-size: 13px;
}
.sidebar-outline__item {
padding: 5px 12px;
.ext-notes-sidebar-outline__item {
padding: 5px var(--sp-3);
font-size: 13px;
color: var(--text-2);
cursor: pointer;
@@ -692,104 +692,104 @@
text-overflow: ellipsis;
transition: var(--transition);
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);
color: var(--text);
}
.sidebar-outline__item--active {
.ext-notes-sidebar-outline__item--active {
background: var(--bg-active);
color: var(--text);
font-weight: 500;
}
.sidebar-outline__item--h1 { font-weight: 600; color: var(--text); }
.sidebar-outline__item--h2 { font-weight: 500; }
.ext-notes-sidebar-outline__item--h1 { font-weight: 600; color: var(--text); }
.ext-notes-sidebar-outline__item--h2 { font-weight: 500; }
/* ── Graph Pane ─────────────────────────── */
.graph-pane {
.ext-notes-graph-pane {
flex: 1;
position: relative;
overflow: hidden;
background: var(--bg-surface, #fff);
background: var(--bg-surface);
min-height: 0;
}
.graph-pane canvas {
.ext-notes-graph-pane canvas {
display: block;
width: 100%;
height: 100%;
cursor: grab;
}
.graph-pane canvas:active { cursor: grabbing; }
.graph-toolbar {
.ext-notes-graph-pane canvas:active { cursor: grabbing; }
.ext-notes-graph-toolbar {
position: absolute;
top: 12px;
right: 12px;
top: var(--sp-3);
right: var(--sp-3);
display: flex;
gap: 8px;
gap: var(--sp-2);
align-items: center;
z-index: 5;
font-size: 12px;
color: var(--text-2, #666);
color: var(--text-2);
}
.graph-toolbar label {
.ext-notes-graph-toolbar label {
display: flex;
align-items: center;
gap: 4px;
gap: var(--sp-1);
cursor: pointer;
user-select: none;
}
.graph-toolbar input[type="checkbox"] { margin: 0; }
.graph-tooltip {
.ext-notes-graph-toolbar input[type="checkbox"] { margin: 0; }
.ext-notes-graph-tooltip {
position: absolute;
pointer-events: none;
background: var(--bg-raised, #fff);
border: 1px solid var(--border, #ddd);
background: var(--bg-raised);
border: 1px solid var(--border);
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);
z-index: 10;
max-width: 220px;
font-size: 12px;
}
.graph-tooltip__title {
.ext-notes-graph-tooltip__title {
font-weight: 600;
font-size: 13px;
margin-bottom: 2px;
}
.graph-tooltip__tags {
.ext-notes-graph-tooltip__tags {
display: flex;
gap: 4px;
gap: var(--sp-1);
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;
padding: 1px 6px;
border-radius: 8px;
background: var(--bg-hover, #eee);
color: var(--text-2, #666);
padding: 1px var(--sp-2);
border-radius: var(--radius);
background: var(--bg-hover);
color: var(--text-2);
}
.graph-tooltip__edges {
.ext-notes-graph-tooltip__edges {
font-size: 11px;
color: var(--text-3, #999);
margin-top: 4px;
color: var(--text-3);
margin-top: var(--sp-1);
}
.graph-empty {
.ext-notes-graph-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-3, #999);
color: var(--text-3);
font-size: 14px;
}
/* ── Responsive ──────────────────────────── */
@media (max-width: 700px) {
.notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
.notes-app { flex-direction: column; }
.folder-tree { max-height: 30vh; }
.notes-editor__body--split { grid-template-columns: 1fr; }
.notes-editor__body--split .notes-preview { display: none; }
.graph-pane { min-height: 50vh; }
@media (max-width: 768px) {
.ext-notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; }
.ext-notes-app { flex-direction: column; }
.ext-notes-folder-tree { max-height: 30vh; }
.ext-notes-editor__body--split { grid-template-columns: 1fr; }
.ext-notes-editor__body--split .ext-notes-preview { display: none; }
.ext-notes-graph-pane { min-height: 50vh; }
}

View File

@@ -132,16 +132,16 @@
}
return html`
<div class="tag-input">
<div class="ext-notes-tag-input">
${(tags || []).map(function(tag) {
return html`
<span key=${tag} class="tag-pill">
<span key=${tag} class="ext-notes-tag-pill">
${tag}
<span class="tag-pill__remove" onClick=${function() { removeTag(tag); }}>×</span>
<span class="ext-notes-tag-pill__remove" onClick=${function() { removeTag(tag); }}>×</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)…'}
value=${input}
onInput=${handleInput}
@@ -149,9 +149,9 @@
onFocus=${function() { if (input.trim()) setShowAC(true); }}
onBlur=${function() { setTimeout(function() { setShowAC(false); }, 150); }} />
${showAC && suggestions.length > 0 && html`
<div class="tag-autocomplete">
<div class="ext-notes-tag-autocomplete">
${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>`;
})}
</div>
@@ -169,17 +169,17 @@
if (!tags || tags.length === 0) return null;
return html`
<div class="tag-filter">
<div class="tag-filter__label">
<div class="ext-notes-tag-filter">
<div class="ext-notes-tag-filter__label">
<span>Tags</span>
${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 class="tag-filter__pills">
<div class="ext-notes-tag-filter__pills">
${tags.map(function(tag) {
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); }}>
${tag}
</span>
@@ -216,23 +216,23 @@
}
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}
draggable="true"
onDragStart=${handleDragStart}
onDragEnd=${handleDragEnd}>
<div class="note-card__title">
${note.pinned ? html`<span class="note-card__pin">📌</span>` : null}
<div class="ext-notes-note-card__title">
${note.pinned ? html`<span class="ext-notes-note-card__pin">📌</span>` : null}
${note.title || 'Untitled'}
</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`
<div class="note-card__tags">
${showTags.map(function(t) { return html`<span key=${t} class="tag-pill">${t}</span>`; })}
${overflow > 0 && html`<span class="tag-pill tag-pill--overflow">+${overflow}</span>`}
<div class="ext-notes-note-card__tags">
${showTags.map(function(t) { return html`<span key=${t} class="ext-notes-tag-pill">${t}</span>`; })}
${overflow > 0 && html`<span class="ext-notes-tag-pill ext-notes-tag-pill--overflow">+${overflow}</span>`}
</div>
`}
${dateStr && html`<div class="note-card__date">${dateStr}</div>`}
${dateStr && html`<div class="ext-notes-note-card__date">${dateStr}</div>`}
</div>
`;
}
@@ -254,10 +254,10 @@
}, [onClose]);
return html`
<div ref=${menuRef} class="folder-context-menu" style="top: ${y}px; left: ${x}px">
<div class="folder-context-menu__item" onClick=${onAddSub}>Add subfolder</div>
<div class="folder-context-menu__item" onClick=${onRename}>Rename</div>
<div class="folder-context-menu__item folder-context-menu__item--danger" onClick=${onDelete}>Delete</div>
<div ref=${menuRef} class="ext-notes-folder-context-menu" style="top: ${y}px; left: ${x}px">
<div class="ext-notes-folder-context-menu__item" onClick=${onAddSub}>Add subfolder</div>
<div class="ext-notes-folder-context-menu__item" onClick=${onRename}>Rename</div>
<div class="ext-notes-folder-context-menu__item ext-notes-folder-context-menu__item--danger" onClick=${onDelete}>Delete</div>
</div>
`;
}
@@ -310,25 +310,25 @@
return html`
<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"
onClick=${function() { onSelect(node.folder.id); }}
onContextMenu=${handleContext}
onDragOver=${handleDragOver}
onDragLeave=${handleDragLeave}
onDrop=${handleDrop}>
<span class="folder-tree__toggle" onClick=${handleToggle}>
<span class="ext-notes-folder-tree__toggle" onClick=${handleToggle}>
${hasChildren ? (expanded ? '▼' : '▶') : '·'}
</span>
<span class="folder-tree__icon">${isActive ? '📂' : '📁'}</span>
<span class="ext-notes-folder-tree__icon">${isActive ? '📂' : '📁'}</span>
${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); }}
onBlur=${handleRename}
onKeyDown=${function(e) { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setEditing(false); }}
onClick=${function(e) { e.stopPropagation(); }}
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>
${menu && html`<${FolderContextMenu} x=${menu.x} y=${menu.y}
@@ -399,18 +399,18 @@
var unfiledDrop = makeDropHandlers(setDropUnfiled);
return html`
<div class="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">
<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}
onDragOver=${allDrop.onDragOver} onDragLeave=${allDrop.onDragLeave} onDrop=${allDrop.onDrop}>
<span class="folder-tree__icon">📋</span>
<span class="folder-tree__name">All Notes</span>
<span class="ext-notes-folder-tree__icon">📋</span>
<span class="ext-notes-folder-tree__name">All Notes</span>
</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}
onDragOver=${unfiledDrop.onDragOver} onDragLeave=${unfiledDrop.onDragLeave} onDrop=${unfiledDrop.onDrop}>
<span class="folder-tree__icon">📄</span>
<span class="folder-tree__name">Unfiled</span>
<span class="ext-notes-folder-tree__icon">📄</span>
<span class="ext-notes-folder-tree__name">Unfiled</span>
</div>
${tree.map(function(node) {
return html`<${FolderNode} key=${node.folder.id} node=${node} depth=${0}
@@ -419,7 +419,7 @@
onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder}
onDropNote=${onDropNote} />`;
})}
<div class="folder-tree__add" onClick=${function() { onCreateFolder(''); }}>
<div class="ext-notes-folder-tree__add" onClick=${function() { onCreateFolder(''); }}>
+ New Folder
</div>
</div>
@@ -446,18 +446,18 @@
if (!backlinks.length) return null;
return html`
<div class="backlinks-panel">
<div class="backlinks-panel__header" onClick=${function() { setCollapsed(!collapsed); }}>
<span class="backlinks-panel__toggle">${collapsed ? '▶' : '▼'}</span>
<div class="ext-notes-backlinks-panel">
<div class="ext-notes-backlinks-panel__header" onClick=${function() { setCollapsed(!collapsed); }}>
<span class="ext-notes-backlinks-panel__toggle">${collapsed ? '▶' : '▼'}</span>
<span>Backlinks</span>
<span class="backlinks-panel__count">${backlinks.length}</span>
<span class="ext-notes-backlinks-panel__count">${backlinks.length}</span>
</div>
${!collapsed && backlinks.map(function(bl) {
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); }}>
<span class="backlinks-panel__title">${bl.source_title}</span>
<span class="backlinks-panel__context">via [[${bl.link_text}]]</span>
<span class="ext-notes-backlinks-panel__title">${bl.source_title}</span>
<span class="ext-notes-backlinks-panel__context">via [[${bl.link_text}]]</span>
</div>
`;
})}
@@ -547,10 +547,10 @@
function SidebarTabs({ activeTab, onTabChange, noteSelected }) {
return html`
<div class="sidebar-tabs">
<button class="sidebar-tabs__tab ${activeTab === 'notes' ? 'sidebar-tabs__tab--active' : ''}"
<div class="ext-ext-notes-sidebar-tabs">
<button class="ext-ext-notes-sidebar-tabs__tab ${activeTab === 'notes' ? 'ext-ext-notes-sidebar-tabs__tab--active' : ''}"
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>
</div>
`;
@@ -563,14 +563,14 @@
function SidebarOutline({ headings, activeHeadingIdx, onScrollToHeading }) {
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`
<div class="sidebar-outline">
<div class="ext-ext-notes-sidebar-outline">
${headings.map(function(h, i) {
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"
onClick=${function() { onScrollToHeading(h); }}>
${h.text}
@@ -772,8 +772,8 @@
for (var i = 0; i < outgoingLinks.length; i++) {
var link = outgoingLinks[i];
if (!link.target_id) {
var search = 'class="wikilink" data-wikilink="' + _escAttr(link.link_text) + '"';
var replace = 'class="wikilink wikilink--unresolved" data-wikilink="' + _escAttr(link.link_text) + '"';
var search = 'class="ext-notes-wikilink" 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);
}
}
@@ -787,7 +787,7 @@
if (!el || viewMode === 'edit') return;
function handleClick(e) {
var target = e.target;
if (target.classList && target.classList.contains('wikilink')) {
if (target.classList && target.classList.contains('ext-notes-wikilink')) {
e.preventDefault();
var linkText = target.getAttribute('data-wikilink');
if (linkText && onNavigateToTitle) onNavigateToTitle(linkText);
@@ -939,44 +939,44 @@
}, [folders]);
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 ─────────────────────
function renderEditorBody() {
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') {
var editor = hasCM
? html`<div class="notes-editor__cm" ref=${cmContainerRef} />`
: html`<textarea class="notes-editor__textarea" ref=${textareaRef}
? html`<div class="ext-notes-editor__cm" ref=${cmContainerRef} />`
: html`<textarea class="ext-notes-editor__textarea" ref=${textareaRef}
value=${body} onInput=${handleBodyChange}
onKeyDown=${handleKeyDown}
placeholder="Start writing in Markdown…" />`;
return [
editor,
html`<div class="notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
html`<div class="ext-notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
];
}
// edit mode
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}
onKeyDown=${handleKeyDown}
placeholder="Start writing in Markdown…" />`;
}
return html`
<div class="notes-editor">
<div class="notes-editor__header">
<input class="notes-editor__title-input" type="text"
<div class="ext-notes-editor">
<div class="ext-notes-editor__header">
<input class="ext-notes-editor__title-input" type="text"
value=${title} onInput=${handleTitleChange}
placeholder="Note title" />
<div class="notes-editor__actions">
<select class="notes-editor__folder-select"
<div class="ext-notes-editor__actions">
<select class="ext-notes-editor__folder-select"
value=${note.folder_id || ''}
onChange=${async function(e) {
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>`;
})}
</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')}
</span>
<button class="notes-toggle" onClick=${function() {
<button class="ext-notes-toggle" onClick=${function() {
var next = viewMode === 'rendered' ? 'edit' : viewMode === 'edit' ? 'split' : 'rendered';
_modeStore.set('editor_mode', next);
setViewMode(next);
}}>
${viewMode === 'rendered' ? 'Edit' : viewMode === 'edit' ? 'Split' : 'Read'}
</button>
<button class="notes-btn" onClick=${handleExport} title="Export as .md">Export</button>
<button class="notes-btn" onClick=${handlePin}
<button class="ext-notes-btn" onClick=${handleExport} title="Export as .md">Export</button>
<button class="ext-notes-btn" onClick=${handlePin}
title=${note.pinned ? 'Unpin' : 'Pin'}>
${note.pinned ? '📌' : '📍'}
</button>
<button class="notes-btn notes-btn--danger" onClick=${handleArchive}
<button class="ext-notes-btn ext-notes-btn--danger" onClick=${handleArchive}
title="Archive">🗑</button>
</div>
</div>
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
<div class="notes-editor__content">
<div class="notes-editor__body ${viewMode === 'split' ? 'notes-editor__body--split' : ''}">
<div class="ext-notes-editor__content">
<div class="ext-notes-editor__body ${viewMode === 'split' ? 'ext-notes-editor__body--split' : ''}">
${renderEditorBody()}
</div>
</div>
@@ -1405,17 +1405,17 @@
}, []);
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) {
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;
return html`
<div class="graph-pane">
<div class="graph-toolbar">
<div class="ext-notes-graph-pane">
<div class="ext-notes-graph-toolbar">
<label>
<input type="checkbox" checked=${hideOrphans}
onChange=${function(e) { setHideOrphans(e.target.checked); }} />
@@ -1430,14 +1430,14 @@
onMouseUp=${handleMouseUp}
onMouseLeave=${handleMouseUp} />
${hoveredNode && html`
<div class="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" style=${{ left: (mousePos.x + 14) + 'px', top: (mousePos.y - 10) + 'px' }}>
<div class="ext-notes-graph-tooltip__title">${hoveredNode.title}</div>
${hoveredNode.tags && hoveredNode.tags.length > 0 && html`
<div class="graph-tooltip__tags">
${hoveredNode.tags.map(function(t) { return html`<span class="tag-pill" key=${t}>${t}</span>`; })}
<div class="ext-notes-graph-tooltip__tags">
${hoveredNode.tags.map(function(t) { return html`<span class="ext-notes-tag-pill" key=${t}>${t}</span>`; })}
</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>
@@ -1622,7 +1622,7 @@
setShowUnfiled(true);
}
async function handleCreateFolder(parentId) {
var name = prompt('Folder name:');
var name = await sw.prompt('Folder name:');
if (!name || !name.trim()) return;
try {
await api.post('/folders', { name: name.trim(), parent_id: parentId || '' });
@@ -1736,8 +1736,8 @@
<${Button} variant=${showGraph ? 'primary' : 'secondary'} size="sm"
onClick=${function() { setShowGraph(!showGraph); }}>Graph<//>
<//>
<div class="notes-app">
<div class="notes-sidebar">
<div class="ext-notes-app">
<div class="ext-notes-sidebar">
<${SidebarTabs} activeTab=${sidebarTab} onTabChange=${setSidebarTab} noteSelected=${!!activeNote} />
${sidebarTab === 'notes' && html`
<${FolderTree} folders=${folders}
@@ -1749,14 +1749,14 @@
onDeleteFolder=${handleDeleteFolder}
onDropNote=${handleDropNote} />
<${TagFilter} tags=${allTags} activeTag=${activeTag} onSelect=${handleTagFilter} />
<div class="notes-search">
<div class="ext-notes-search">
<input type="text" placeholder="Search notes…"
value=${searchTerm} onInput=${handleSearch} />
</div>
<div class="notes-list">
${loading && html`<div class="notes-list__loading"><${Spinner} size="md" /></div>`}
<div class="ext-notes-list">
${loading && html`<div class="ext-notes-list__loading"><${Spinner} size="md" /></div>`}
${!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.')}
</div>
`}

View File

@@ -1,6 +1,6 @@
/* ── Schedules Surface ───────────────────── */
.sched-app {
.ext-schedules-app {
display: flex;
flex-direction: column;
height: 100%;
@@ -8,53 +8,53 @@
color: var(--text);
}
.sched-header {
.ext-schedules-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
}
.sched-header h1 {
.ext-schedules-header h1 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.sched-body {
.ext-schedules-body {
flex: 1;
overflow: auto;
padding: 16px;
padding: var(--sp-4);
display: flex;
flex-direction: column;
gap: 16px;
gap: var(--sp-4);
}
.sched-count {
.ext-schedules-count {
font-size: 0.8rem;
color: var(--text-3);
}
/* ── Table ────────────────────────────────── */
.sched-list__loading,
.sched-list__empty {
padding: 40px 16px;
.ext-schedules-list__loading,
.ext-schedules-list__empty {
padding: var(--sp-10) var(--sp-4);
text-align: center;
color: var(--text-3);
font-size: 0.85rem;
}
.sched-table {
.ext-schedules-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.sched-table th {
.ext-schedules-table th {
text-align: left;
padding: 8px 12px;
padding: var(--sp-2) var(--sp-3);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
@@ -63,19 +63,19 @@
border-bottom: 1px solid var(--border);
}
.sched-table td {
padding: 10px 12px;
.ext-schedules-table td {
padding: var(--sp-3) var(--sp-3);
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.sched-row:hover {
.ext-schedules-row: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;
border: none;
color: var(--accent);
@@ -85,86 +85,86 @@
padding: 0;
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;
color: var(--text-3);
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-size: 0.8rem;
background: var(--bg-raised);
padding: 2px 6px;
border-radius: 4px;
padding: 2px var(--sp-2);
border-radius: var(--radius-sm);
color: var(--text-2);
}
.sched-cron-human {
.ext-schedules-cron-human {
font-size: 0.75rem;
color: var(--text-3);
margin-top: 2px;
}
.sched-row__next {
.ext-schedules-row__next {
font-size: 0.8rem;
color: var(--text-2);
white-space: nowrap;
}
.sched-row__actions {
.ext-schedules-row__actions {
display: flex;
gap: 4px;
gap: var(--sp-1);
white-space: nowrap;
}
/* ── Toggle button ────────────────────────── */
.sched-toggle-btn {
.ext-schedules-toggle-btn {
font-size: 0.75rem;
font-weight: 600;
padding: 2px 10px;
border-radius: 10px;
padding: 2px var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.sched-toggle-btn--on {
.ext-schedules-toggle-btn--on {
background: var(--success);
color: #fff;
border-color: var(--success);
}
.sched-toggle-btn--off {
.ext-schedules-toggle-btn--off {
background: var(--bg-raised);
color: var(--text-3);
}
.sched-toggle-btn:hover { opacity: 0.85; }
.ext-schedules-toggle-btn:hover { opacity: 0.85; }
/* ── Action buttons ───────────────────────── */
.sched-btn {
.ext-schedules-btn {
background: none;
border: 1px solid var(--border);
color: var(--text-2);
border-radius: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 0.15s;
}
.sched-btn:hover { background: var(--bg-hover); }
.ext-schedules-btn:hover { background: var(--bg-hover); }
.sched-btn--sm {
padding: 3px 8px;
.ext-schedules-btn--sm {
padding: 3px var(--sp-2);
font-size: 0.8rem;
}
.sched-btn--danger:hover {
.ext-schedules-btn--danger:hover {
background: var(--danger-dim);
color: var(--danger);
border-color: var(--danger);
@@ -172,92 +172,92 @@
/* ── Form ─────────────────────────────────── */
.sched-form { display: flex; flex-direction: column; gap: 12px; }
.sched-form__row { display: flex; align-items: center; gap: 12px; }
.sched-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
.ext-schedules-form { display: flex; flex-direction: column; gap: var(--sp-3); }
.ext-schedules-form__row { display: flex; align-items: center; gap: var(--sp-3); }
.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;
}
.sched-cron-preview {
.ext-schedules-cron-preview {
font-size: 0.8rem;
color: var(--accent);
margin-top: 4px;
margin-top: var(--sp-1);
font-style: italic;
}
.sched-script {
.ext-schedules-script {
font-family: monospace;
font-size: 0.85rem;
resize: vertical;
}
.sched-toggle {
.ext-schedules-toggle {
display: flex;
align-items: center;
gap: 8px;
gap: var(--sp-2);
font-size: 0.85rem;
cursor: pointer;
}
/* ── Logs panel ───────────────────────────── */
.sched-logs {
.ext-schedules-logs {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius);
overflow: hidden;
}
.sched-logs__header {
.ext-schedules-logs__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
font-weight: 600;
}
.sched-logs__loading,
.sched-logs__empty {
padding: 20px 12px;
.ext-schedules-logs__loading,
.ext-schedules-logs__empty {
padding: var(--sp-5) var(--sp-3);
text-align: center;
color: var(--text-3);
font-size: 0.8rem;
}
.sched-log-entry {
.ext-schedules-log-entry {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border);
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;
width: 18px;
text-align: center;
}
.sched-log-entry__status--ok { color: var(--success); }
.sched-log-entry__status--fail { color: var(--danger); }
.ext-schedules-log-entry__status--ok { color: var(--success); }
.ext-schedules-log-entry__status--fail { color: var(--danger); }
.sched-log-entry__time {
.ext-schedules-log-entry__time {
color: var(--text-2);
white-space: nowrap;
}
.sched-log-entry__duration {
.ext-schedules-log-entry__duration {
color: var(--text-3);
font-family: monospace;
font-size: 0.75rem;
}
.sched-log-entry__error {
.ext-schedules-log-entry__error {
color: var(--danger);
font-size: 0.75rem;
overflow: hidden;
@@ -269,9 +269,9 @@
/* ── Responsive ───────────────────────────── */
@media (max-width: 768px) {
.sched-table th:nth-child(3),
.sched-table td:nth-child(3) {
.ext-schedules-table th:nth-child(3),
.ext-schedules-table td:nth-child(3) {
display: none;
}
.sched-body { padding: 8px; }
.ext-schedules-body { padding: var(--sp-2); }
}

View File

@@ -148,7 +148,7 @@
return html`
<${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} />`}
<${FormField} label="Name" required>
<input type="text" value=${name} onInput=${e => setName(e.target.value)}
@@ -160,22 +160,22 @@
<//>
<${FormField} label="Cron Expression" required>
<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`
<div class="sched-cron-preview">${preview}</div>
<div class="ext-schedules-cron-preview">${preview}</div>
`}
<//>
<${FormField} label="Script">
<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">
<label class="sched-toggle">
<div class="ext-schedules-form__row">
<label class="ext-schedules-toggle">
<input type="checkbox" checked=${enabled} onChange=${e => setEnabled(e.target.checked)} />
<span>Enabled</span>
</label>
</div>
<div class="sched-form__actions">
<div class="ext-schedules-form__actions">
<${Button} variant="ghost" type="button" onClick=${onClose}>Cancel<//>
<${Button} variant="primary" type="submit" disabled=${saving}>
${saving ? 'Saving…' : (schedule ? 'Update' : 'Create')}
@@ -208,25 +208,25 @@
}, [scheduleId]);
return html`
<div class="sched-logs">
<div class="sched-logs__header">
<div class="ext-schedules-logs">
<div class="ext-schedules-logs__header">
<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>
${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`
<div class="sched-logs__empty">No executions yet</div>
<div class="ext-schedules-logs__empty">No executions yet</div>
`}
${!loading && logs.map(function (log) {
var success = log.status === 'success' || log.status === 'ok';
return html`
<div class="sched-log-entry" key=${log.id || log.started_at}>
<span class="sched-log-entry__status sched-log-entry__status--${success ? 'ok' : 'fail'}">
<div class="ext-schedules-log-entry" key=${log.id || log.started_at}>
<span class="ext-schedules-log-entry__status ext-schedules-log-entry__status--${success ? 'ok' : 'fail'}">
${success ? '✓' : '✗'}
</span>
<span class="sched-log-entry__time">${_formatTime(log.started_at)}</span>
<span class="sched-log-entry__duration">${_formatDuration(log.duration_ms)}</span>
${log.error && html`<span class="sched-log-entry__error" title=${log.error}>${log.error}</span>`}
<span class="ext-schedules-log-entry__time">${_formatTime(log.started_at)}</span>
<span class="ext-schedules-log-entry__duration">${_formatDuration(log.duration_ms)}</span>
${log.error && html`<span class="ext-schedules-log-entry__error" title=${log.error}>${log.error}</span>`}
</div>
`;
})}
@@ -286,13 +286,13 @@
}
return html`
<div class="sched-list">
${loading && html`<div class="sched-list__loading"><${Spinner} size="md" /></div>`}
<div class="ext-schedules-list">
${loading && html`<div class="ext-schedules-list__loading"><${Spinner} size="md" /></div>`}
${!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`
<table class="sched-table">
<table class="ext-schedules-table">
<thead>
<tr>
<th>Name</th>
@@ -305,27 +305,27 @@
<tbody>
${items.map(function (s) {
return html`
<tr key=${s.id} class="sched-row">
<td class="sched-row__name">
<button class="sched-link" onClick=${() => onEdit(s)}>${s.name}</button>
${s.description && html`<div class="sched-row__desc">${s.description}</div>`}
<tr key=${s.id} class="ext-schedules-row">
<td class="ext-schedules-row__name">
<button class="ext-schedules-link" onClick=${() => onEdit(s)}>${s.name}</button>
${s.description && html`<div class="ext-schedules-row__desc">${s.description}</div>`}
</td>
<td class="sched-row__cron">
<span class="sched-cron-badge">${s.cron_expr}</span>
<div class="sched-cron-human">${describeCron(s.cron_expr)}</div>
<td class="ext-schedules-row__cron">
<span class="ext-schedules-cron-badge">${s.cron_expr}</span>
<div class="ext-schedules-cron-human">${describeCron(s.cron_expr)}</div>
</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>
<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)}
title=${s.enabled ? 'Disable' : 'Enable'}>
${s.enabled ? 'On' : 'Off'}
</button>
</td>
<td class="sched-row__actions">
<button class="sched-btn sched-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="sched-btn sched-btn--sm sched-btn--danger" onClick=${() => handleDelete(s)}
<td class="ext-schedules-row__actions">
<button class="ext-schedules-btn ext-schedules-btn--sm" onClick=${() => handleRun(s)} title="Run now">▶</button>
<button class="ext-schedules-btn ext-schedules-btn--sm" onClick=${() => onShowLogs(s.id)} title="View logs">📋</button>
<button class="ext-schedules-btn ext-schedules-btn--sm ext-schedules-btn--danger" onClick=${() => handleDelete(s)}
title="Delete">×</button>
</td>
</tr>
@@ -371,14 +371,14 @@
}
return html`
<div class="sched-app">
<div class="ext-schedules-app">
${Topbar ? html`
<${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<//>
<//>
` : html`
<div class="sched-header">
<div class="ext-schedules-header">
<h1>Schedules</h1>
<${Button} variant="primary" size="sm" onClick=${() => setEditing({})}>+ New Schedule<//>
</div>
@@ -386,7 +386,7 @@
${error && html`<${Banner} variant="danger" text=${error} />`}
<div class="sched-body">
<div class="ext-schedules-body">
<${ScheduleList}
items=${items}
loading=${loading}

View File

@@ -1,222 +1,222 @@
/* SDK Test Runner — Styles */
.sdkr-header {
.ext-sdk-test-runner-header {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 4px;
gap: var(--sp-3);
margin-bottom: var(--sp-1);
}
.sdkr-header h1 {
.ext-sdk-test-runner-header h1 {
font-size: 20px;
font-weight: 600;
margin: 0;
color: var(--text, #e0e0e0);
color: var(--text);
}
.sdkr-version {
.ext-sdk-test-runner-version {
font-size: 12px;
color: var(--text3, #888);
color: var(--text-3);
font-family: 'JetBrains Mono', monospace;
}
.sdkr-desc {
.ext-sdk-test-runner-desc {
font-size: 13px;
color: var(--text2, #aaa);
margin: 0 0 16px 0;
color: var(--text-2);
margin: 0 0 var(--sp-4) 0;
line-height: 1.5;
}
/* Fixtures panel */
.sdkr-fixtures {
margin-bottom: 16px;
padding: 10px 14px;
border: 1px solid var(--border, #333);
border-radius: 6px;
background: var(--bg1, #1a1a1a);
.ext-sdk-test-runner-fixtures {
margin-bottom: var(--sp-4);
padding: var(--sp-3) var(--sp-4);
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-raised);
}
.sdkr-fixture-row {
.ext-sdk-test-runner-fixture-row {
display: flex;
align-items: center;
gap: 10px;
gap: var(--sp-3);
flex-wrap: wrap;
}
.sdkr-fixture-label {
.ext-sdk-test-runner-fixture-label {
font-size: 12px;
font-weight: 600;
color: var(--text2, #aaa);
color: var(--text-2);
text-transform: uppercase;
white-space: nowrap;
}
.sdkr-select {
padding: 4px 8px;
.ext-sdk-test-runner-select {
padding: var(--sp-1) var(--sp-2);
font-size: 13px;
background: var(--bg2, #222);
color: var(--text, #e0e0e0);
border: 1px solid var(--border, #444);
border-radius: 4px;
background: var(--bg-raised);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
min-width: 130px;
}
.sdkr-input {
padding: 4px 8px;
.ext-sdk-test-runner-input {
padding: var(--sp-1) var(--sp-2);
font-size: 13px;
background: var(--bg2, #222);
color: var(--text, #e0e0e0);
border: 1px solid var(--border, #444);
border-radius: 4px;
background: var(--bg-raised);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
flex: 1;
min-width: 200px;
max-width: 400px;
font-family: 'JetBrains Mono', monospace;
}
.sdkr-input::placeholder {
color: var(--text3, #666);
.ext-sdk-test-runner-input::placeholder {
color: var(--text-3);
font-family: inherit;
}
.sdkr-fixture-status {
.ext-sdk-test-runner-fixture-status {
font-size: 12px;
color: var(--text3, #888);
color: var(--text-3);
font-family: 'JetBrains Mono', monospace;
}
.sdkr-fixture-note {
.ext-sdk-test-runner-fixture-note {
font-size: 11px;
color: var(--text3, #666);
margin-top: 6px;
color: var(--text-3);
margin-top: var(--sp-2);
line-height: 1.4;
}
/* Controls */
.sdkr-controls {
.ext-sdk-test-runner-controls {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.sdkr-filters {
.ext-sdk-test-runner-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
gap: var(--sp-2);
}
.sdkr-filter-label {
.ext-sdk-test-runner-filter-label {
font-size: 12px;
font-weight: 600;
color: var(--text2, #aaa);
color: var(--text-2);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sdkr-check {
.ext-sdk-test-runner-check {
font-size: 13px;
color: var(--text, #e0e0e0);
color: var(--text);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 3px;
}
.sdkr-check input { cursor: pointer; }
.sdkr-btn-row {
.ext-sdk-test-runner-check input { cursor: pointer; }
.ext-sdk-test-runner-btn-row {
display: flex;
gap: 8px;
gap: var(--sp-2);
}
.sdkr-btn {
padding: 6px 14px;
border-radius: 6px;
border: 1px solid var(--border, #444);
background: var(--bg2, #2a2a2a);
color: var(--text, #e0e0e0);
.ext-sdk-test-runner-btn {
padding: var(--sp-2) var(--sp-4);
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text);
font-size: 13px;
cursor: pointer;
transition: background 0.15s;
}
.sdkr-btn:hover { background: var(--bg3, #383838); }
.sdkr-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.sdkr-btn-primary {
background: var(--accent, #3b82f6);
border-color: var(--accent, #3b82f6);
.ext-sdk-test-runner-btn:hover { background: var(--bg-hover); }
.ext-sdk-test-runner-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.ext-sdk-test-runner-btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.sdkr-btn-primary:hover { filter: brightness(1.15); }
.sdkr-btn-danger {
background: var(--danger, #ef4444);
border-color: var(--danger, #ef4444);
.ext-sdk-test-runner-btn-primary:hover { filter: brightness(1.15); }
.ext-sdk-test-runner-btn-danger {
background: var(--danger);
border-color: var(--danger);
color: #fff;
}
/* Summary */
.sdkr-summary {
.ext-sdk-test-runner-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
padding: 10px 12px;
background: var(--bg2, #1e1e1e);
border-radius: 8px;
border: 1px solid var(--border, #333);
gap: var(--sp-2);
margin-bottom: var(--sp-3);
padding: var(--sp-3) var(--sp-3);
background: var(--bg-raised);
border-radius: var(--radius);
border: 1px solid var(--border);
}
.sdkr-badge {
.ext-sdk-test-runner-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 4px;
padding: var(--sp-1) var(--sp-3);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
}
/* Table */
.sdkr-table-wrap {
.ext-sdk-test-runner-table-wrap {
overflow-x: auto;
border: 1px solid var(--border, #333);
border-radius: 8px;
border: 1px solid var(--border);
border-radius: var(--radius);
}
.sdkr-table {
.ext-sdk-test-runner-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.sdkr-table th {
.ext-sdk-test-runner-table th {
text-align: left;
padding: 8px 10px;
background: var(--bg2, #1e1e1e);
color: var(--text2, #aaa);
padding: var(--sp-2) var(--sp-3);
background: var(--bg-raised);
color: var(--text-2);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border, #333);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 1;
}
.sdkr-table td {
padding: 6px 10px;
border-bottom: 1px solid var(--border-subtle, #2a2a2a);
color: var(--text, #e0e0e0);
.ext-sdk-test-runner-table td {
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border-subtle);
color: var(--text);
vertical-align: top;
}
.sdkr-table tbody tr:hover {
background: var(--bg-hover, rgba(255,255,255,0.03));
.ext-sdk-test-runner-table tbody tr:hover {
background: var(--bg-hover);
}
.sdkr-test-name {
.ext-sdk-test-runner-test-name {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
.sdkr-verdict {
.ext-sdk-test-runner-verdict {
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
}
.sdkr-ms {
.ext-sdk-test-runner-ms {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
color: var(--text3, #888);
color: var(--text-3);
text-align: right;
}
.sdkr-detail {
.ext-sdk-test-runner-detail {
font-size: 11px;
color: var(--text3, #888);
color: var(--text-3);
max-width: 400px;
word-break: break-word;
}
/* Row verdict highlights */
.sdkr-row-sdk_bug td { background: rgba(245, 158, 11, 0.06); }
.sdkr-row-icd_bug td { background: rgba(239, 68, 68, 0.06); }
.sdkr-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-sdk_bug td { background: rgba(245, 158, 11, 0.06); }
.ext-sdk-test-runner-row-icd_bug td { background: rgba(239, 68, 68, 0.06); }
.ext-sdk-test-runner-row-shape_bug td { background: rgba(168, 85, 247, 0.06); }
.ext-sdk-test-runner-row-error td { background: rgba(239, 68, 68, 0.08); }

View File

@@ -58,25 +58,25 @@
mount.innerHTML = '';
// Header
var header = el('div', { className: 'sdkr-header' }, [
var header = el('div', { className: 'ext-sdk-test-runner-header' }, [
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);
// 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. ' +
'SDK fail + ICD pass = SDK bug. Both fail = ICD bug.'));
// ── 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' });
fixRow.appendChild(el('span', { className: 'sdkr-fixture-label' }, 'FIXTURES:'));
var fixRow = el('div', { className: 'ext-sdk-test-runner-fixture-row' });
fixRow.appendChild(el('span', { className: 'ext-sdk-test-runner-fixture-label' }, 'FIXTURES:'));
// 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 —'));
['openrouter', 'openai', 'anthropic', 'venice'].forEach(function (p) {
provSelect.appendChild(el('option', { value: p }, p));
@@ -85,18 +85,18 @@
// API key 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)'
});
fixRow.appendChild(keyInput);
// Fixture status
var fixStatus = el('span', { className: 'sdkr-fixture-status' }, '');
var fixStatus = el('span', { className: 'ext-sdk-test-runner-fixture-status' }, '');
fixRow.appendChild(fixStatus);
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. ' +
'Without a key, those tests are skipped. Key is never stored.');
fixturePanel.appendChild(fixNote);
@@ -107,16 +107,16 @@
T._fixtureInput = { provSelect: provSelect, keyInput: keyInput, statusEl: fixStatus };
// Controls row
var controlRow = el('div', { className: 'sdkr-controls' });
var controlRow = el('div', { className: 'ext-sdk-test-runner-controls' });
// Domain filter checkboxes
var filterBox = el('div', { className: 'sdkr-filters' });
filterBox.appendChild(el('span', { className: 'sdkr-filter-label' }, 'Domains: '));
var filterBox = el('div', { className: 'ext-sdk-test-runner-filters' });
filterBox.appendChild(el('span', { className: 'ext-sdk-test-runner-filter-label' }, 'Domains: '));
var domainNames = Object.keys(T.domains).sort();
var domainChecks = {};
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' });
domainChecks[name] = cb;
label.appendChild(cb);
@@ -126,11 +126,11 @@
controlRow.appendChild(filterBox);
// Buttons
var btnRow = el('div', { className: 'sdkr-btn-row' });
var runBtn = el('button', { className: 'sdkr-btn sdkr-btn-primary', onClick: onRun }, 'Run All');
var abortBtn = el('button', { className: 'sdkr-btn sdkr-btn-danger', onClick: function () { T.abort(); } }, 'Abort');
var btnRow = el('div', { className: 'ext-sdk-test-runner-btn-row' });
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: 'ext-sdk-test-runner-btn ext-sdk-test-runner-btn-danger', onClick: function () { T.abort(); } }, 'Abort');
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(abortBtn);
btnRow.appendChild(exportBtn);
@@ -138,12 +138,12 @@
mount.appendChild(controlRow);
// Summary bar
var summaryBar = el('div', { className: 'sdkr-summary' });
var summaryBar = el('div', { className: 'ext-sdk-test-runner-summary' });
mount.appendChild(summaryBar);
// Results table
var tableWrap = el('div', { className: 'sdkr-table-wrap' });
var table = el('table', { className: 'sdkr-table' });
var tableWrap = el('div', { className: 'ext-sdk-test-runner-table-wrap' });
var table = el('table', { className: 'ext-sdk-test-runner-table' });
var thead = el('thead', {}, [
el('tr', {}, [
el('th', {}, '#'),
@@ -176,7 +176,7 @@
];
items.forEach(function (item) {
var badge = el('span', {
className: 'sdkr-badge',
className: 'ext-sdk-test-runner-badge',
style: {
background: item[1] > 0 ? COLORS[item[0]] : 'var(--bg2, #333)',
color: item[1] > 0 ? '#fff' : 'var(--text3, #888)'
@@ -184,7 +184,7 @@
}, LABELS[item[0]] + ': ' + item[1]);
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)' }
}, 'Total: ' + total));
}
@@ -197,19 +197,19 @@
if (entry.rawErr) detail += (detail ? ' | ' : '') + 'ICD: ' + esc(entry.rawErr);
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', {}, esc(entry.domain)),
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('span', {
className: 'sdkr-verdict',
className: 'ext-sdk-test-runner-verdict',
style: { color: COLORS[v] || '#fff' }
}, LABELS[v] || v)
]),
el('td', { className: 'sdkr-ms' }, String(entry.durationMs)),
el('td', { className: 'sdkr-detail' }, detail)
el('td', { className: 'ext-sdk-test-runner-ms' }, String(entry.durationMs)),
el('td', { className: 'ext-sdk-test-runner-detail' }, detail)
]);
tbody.appendChild(row);
updateSummary();

View File

@@ -1,9 +1,9 @@
/* ── Tasks Surface Styles ────────────────── */
.tasks-app {
.ext-tasks-app {
max-width: 1100px;
margin: 0 auto;
padding: 24px 20px;
padding: var(--sp-6) var(--sp-5);
height: 100%;
display: flex;
flex-direction: column;
@@ -11,37 +11,37 @@
}
/* ── Header ──────────────────────────────── */
.tasks-header {
.ext-tasks-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
gap: var(--sp-3);
margin-bottom: var(--sp-5);
flex-shrink: 0;
}
.tasks-title {
.ext-tasks-title {
font-size: 20px;
font-weight: 600;
color: var(--text);
}
.tasks-stats {
.ext-tasks-stats {
font-size: 13px;
color: var(--text-3);
}
.tasks-header__right {
.ext-tasks-header__right {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
gap: var(--sp-3);
}
.tasks-view-tabs {
.ext-tasks-view-tabs {
display: flex;
gap: 2px;
background: var(--bg-raised);
border-radius: var(--radius);
padding: 2px;
}
.tasks-view-tab {
padding: 5px 14px;
.ext-tasks-view-tab {
padding: 5px var(--sp-4);
font-size: 13px;
border: none;
background: transparent;
@@ -50,109 +50,109 @@
cursor: pointer;
transition: var(--transition);
}
.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:hover { color: var(--text); background: var(--bg-hover); }
.ext-tasks-view-tab--active { color: var(--text); background: var(--bg-surface); }
/* ── List View ───────────────────────────── */
.task-list {
.ext-tasks-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
gap: var(--sp-2);
}
.task-list__filters {
.ext-tasks-list__filters {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
gap: var(--sp-3);
margin-bottom: var(--sp-2);
flex-shrink: 0;
}
.task-filter {
padding: 5px 10px;
.ext-tasks-filter {
padding: 5px var(--sp-3);
font-size: 13px;
background: var(--bg-raised);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.task-list__count {
.ext-tasks-list__count {
font-size: 12px;
color: var(--text-3);
}
.task-list__loading, .task-list__empty {
.ext-tasks-list__loading, .ext-tasks-list__empty {
display: flex;
justify-content: center;
padding: 40px 0;
padding: var(--sp-10) 0;
color: var(--text-3);
font-size: 14px;
}
/* ── Task Card ───────────────────────────── */
.task-card {
.ext-tasks-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
padding: var(--sp-3) var(--sp-4);
transition: var(--transition);
}
.task-card:hover {
.ext-tasks-card:hover {
border-color: var(--border-light);
background: var(--bg-raised);
}
.task-card__header {
.ext-tasks-card__header {
display: flex;
align-items: center;
gap: 8px;
gap: var(--sp-2);
}
.task-card__priority { font-size: 10px; }
.task-card__title {
.ext-tasks-card__priority { font-size: 10px; }
.ext-tasks-card__title {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--text);
cursor: pointer;
}
.task-card__title:hover { color: var(--accent); }
.task-card__status {
.ext-tasks-card__title:hover { color: var(--accent); }
.ext-tasks-card__status {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
padding: 2px var(--sp-2);
border-radius: var(--radius-lg);
font-weight: 500;
}
.task-card__status--todo { background: var(--accent-dim); color: var(--accent); }
.task-card__status--in_progress { background: var(--warning-dim); color: var(--warning); }
.task-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--todo { background: var(--accent-dim); color: var(--accent); }
.ext-tasks-card__status--in_progress { background: var(--warning-dim); color: var(--warning); }
.ext-tasks-card__status--done { background: var(--success-dim); color: var(--success); }
.ext-tasks-card__status--cancelled { background: var(--bg-raised); color: var(--text-3); }
.task-card__desc {
.ext-tasks-card__desc {
font-size: 13px;
color: var(--text-2);
margin-top: 6px;
margin-top: var(--sp-2);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-card__footer {
.ext-tasks-card__footer {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
gap: var(--sp-3);
margin-top: var(--sp-2);
font-size: 12px;
color: var(--text-3);
}
.task-card__actions {
.ext-tasks-card__actions {
margin-left: auto;
display: flex;
gap: 4px;
gap: var(--sp-1);
opacity: 0;
transition: var(--transition);
}
.task-card:hover .task-card__actions { opacity: 1; }
.ext-tasks-card:hover .ext-tasks-card__actions { opacity: 1; }
/* ── Inline buttons ──────────────────────── */
.task-btn {
.ext-tasks-btn {
border: none;
background: var(--bg-raised);
color: var(--text-2);
@@ -160,86 +160,86 @@
border-radius: var(--radius);
transition: var(--transition);
}
.task-btn--sm { padding: 2px 8px; font-size: 13px; }
.task-btn:hover { background: var(--bg-hover); color: var(--text); }
.task-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
.ext-tasks-btn--sm { padding: 2px var(--sp-2); font-size: 13px; }
.ext-tasks-btn:hover { background: var(--bg-hover); color: var(--text); }
.ext-tasks-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
/* ── Board View ──────────────────────────── */
.task-board {
.ext-tasks-board {
flex: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
gap: var(--sp-3);
overflow-x: auto;
overflow-y: hidden;
}
.task-board__col {
.ext-tasks-board__col {
background: var(--bg-raised);
border-radius: var(--radius);
display: flex;
flex-direction: column;
min-height: 0;
}
.task-board__col-header {
.ext-tasks-board__col-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
padding: var(--sp-3) var(--sp-3);
font-size: 13px;
font-weight: 600;
color: var(--text-2);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.task-board__col-count {
.ext-tasks-board__col-count {
font-size: 11px;
color: var(--text-3);
background: var(--bg-hover);
padding: 1px 7px;
border-radius: 10px;
border-radius: var(--radius-lg);
}
.task-board__col-body {
.ext-tasks-board__col-body {
flex: 1;
overflow-y: auto;
padding: 8px;
padding: var(--sp-2);
display: flex;
flex-direction: column;
gap: 6px;
gap: var(--sp-2);
}
.task-board__card {
.ext-tasks-board__card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
padding: var(--sp-3) var(--sp-3);
cursor: pointer;
transition: var(--transition);
}
.task-board__card:hover {
.ext-tasks-board__card:hover {
border-color: var(--accent);
}
.task-board__card-title {
.ext-tasks-board__card-title {
font-size: 13px;
font-weight: 500;
color: var(--text);
display: flex;
align-items: center;
gap: 6px;
gap: var(--sp-2);
}
.task-board__card-due {
.ext-tasks-board__card-due {
font-size: 11px;
color: var(--text-3);
margin-top: 4px;
margin-top: var(--sp-1);
}
/* ── Form ────────────────────────────────── */
.task-form { display: flex; flex-direction: column; gap: 12px; }
.task-form__row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.task-form__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
.task-form textarea,
.task-form input,
.task-form select {
.ext-tasks-form { display: flex; flex-direction: column; gap: var(--sp-3); }
.ext-tasks-form__row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
.ext-tasks-form__actions { display: flex; justify-content: flex-end; gap: var(--sp-2); margin-top: var(--sp-1); }
.ext-tasks-form textarea,
.ext-tasks-form input,
.ext-tasks-form select {
width: 100%;
padding: 7px 10px;
padding: 7px var(--sp-3);
font-size: 13px;
background: var(--input-bg);
color: var(--text);
@@ -247,34 +247,34 @@
border-radius: var(--radius);
font-family: var(--font);
}
.task-form textarea:focus,
.task-form input:focus,
.task-form select:focus {
.ext-tasks-form textarea:focus,
.ext-tasks-form input:focus,
.ext-tasks-form select:focus {
outline: none;
border-color: var(--accent);
}
/* ── Statusbar widget ────────────────────── */
.task-status-widget {
.ext-tasks-status-widget {
font-size: 12px;
color: var(--text-3);
padding: 4px 10px;
padding: var(--sp-1) var(--sp-3);
}
/* ── Slot containers ─────────────────────── */
.sw-slot {
display: flex;
align-items: center;
gap: 8px;
gap: var(--sp-2);
}
.sw-slot--statusbar {
padding: 4px 12px;
padding: var(--sp-1) var(--sp-3);
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--text-3);
flex-shrink: 0;
}
.sw-slot--toolbar {
padding: 4px 12px;
padding: var(--sp-1) var(--sp-3);
flex-shrink: 0;
}

View File

@@ -88,7 +88,7 @@
return html`
<${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} />`}
<${FormField} label="Title" required>
<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)}
placeholder="Details (optional)" />
<//>
<div class="task-form__row">
<div class="ext-tasks-form__row">
<${FormField} label="Status">
<select value=${status} onChange=${e => setStatus(e.target.value)}>
${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)}
@@ -110,7 +110,7 @@
</select>
<//>
</div>
<div class="task-form__row">
<div class="ext-tasks-form__row">
<${FormField} label="Due Date">
<input type="date" value=${dueDate} onInput=${e => setDueDate(e.target.value)} />
<//>
@@ -119,7 +119,7 @@
placeholder="comma-separated" />
<//>
</div>
<div class="task-form__actions">
<div class="ext-tasks-form__actions">
<${Button} variant="ghost" type="button" onClick=${onClose}>Cancel<//>
<${Button} variant="primary" type="submit" disabled=${saving}>
${saving ? 'Saving…' : (task ? 'Update' : 'Create')}
@@ -155,45 +155,45 @@
}
return html`
<div class="task-list">
<div class="task-list__filters">
<select value=${filter} onChange=${e => setFilter(e.target.value)} class="task-filter">
<div class="ext-tasks-list">
<div class="ext-tasks-list__filters">
<select value=${filter} onChange=${e => setFilter(e.target.value)} class="ext-tasks-filter">
<option value="all">All</option>
${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)}
</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>
${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`
<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) {
return html`
<div class="task-card" key=${t.id}>
<div class="task-card__header">
<span class="task-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">
<div class="ext-tasks-card" key=${t.id}>
<div class="ext-tasks-card__header">
<span class="ext-tasks-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">
</span>
<span class="task-card__title" onClick=${() => onEdit(t)}>${t.title}</span>
<span class="task-card__status task-card__status--${t.status}">
<span class="ext-tasks-card__title" onClick=${() => onEdit(t)}>${t.title}</span>
<span class="ext-tasks-card__status ext-tasks-card__status--${t.status}">
${STATUS_LABELS[t.status] || t.status}
</span>
</div>
${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">
${t.due_date && html`<span class="task-card__due">Due: ${t.due_date}</span>`}
${t.tags && html`<span class="task-card__tags">${t.tags}</span>`}
<div class="task-card__actions">
<div class="ext-tasks-card__footer">
${t.due_date && html`<span class="ext-tasks-card__due">Due: ${t.due_date}</span>`}
${t.tags && html`<span class="ext-tasks-card__tags">${t.tags}</span>`}
<div class="ext-tasks-card__actions">
${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>
`}
<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>
</div>
</div>
@@ -216,25 +216,25 @@
}
return html`
<div class="task-board">
${loading && html`<div class="task-list__loading"><${Spinner} size="md" /></div>`}
<div class="ext-tasks-board">
${loading && html`<div class="ext-tasks-list__loading"><${Spinner} size="md" /></div>`}
${!loading && STATUSES.map(function(status) {
var col = items.filter(function(t) { return t.status === status; });
return html`
<div class="task-board__col" key=${status}>
<div class="task-board__col-header">
<span class="task-board__col-title">${STATUS_LABELS[status] || status}</span>
<span class="task-board__col-count">${col.length}</span>
<div class="ext-tasks-board__col" key=${status}>
<div class="ext-tasks-board__col-header">
<span class="ext-tasks-board__col-title">${STATUS_LABELS[status] || status}</span>
<span class="ext-tasks-board__col-count">${col.length}</span>
</div>
<div class="task-board__col-body">
<div class="ext-tasks-board__col-body">
${col.map(function(t) {
return html`
<div class="task-board__card" key=${t.id} onClick=${() => onEdit(t)}>
<div class="task-board__card-title">
<span class="task-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">●</span>
<div class="ext-tasks-board__card" key=${t.id} onClick=${() => onEdit(t)}>
<div class="ext-tasks-board__card-title">
<span class="ext-tasks-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">●</span>
${t.title}
</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>
`;
})}
@@ -307,22 +307,22 @@
var Topbar = sw.shell?.Topbar;
return html`
<div class="tasks-app">
<div class="ext-tasks-app">
${Topbar ? html`
<${Topbar} title="Tasks">
${stats && html`<span class="tasks-stats">${stats.total || 0} total</span>`}
<div class="tasks-view-tabs">
${stats && html`<span class="ext-tasks-stats">${stats.total || 0} total</span>`}
<div class="ext-tasks-view-tabs">
${tabs.map(function(tab) {
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>`;
})}
</div>
<${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//>
<//>
` : html`
<div class="tasks-header">
<h1 class="tasks-title">Tasks</h1>
<div class="ext-tasks-header">
<h1 class="ext-tasks-title">Tasks</h1>
<${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//>
</div>
`}
@@ -381,7 +381,7 @@
}, []);
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', {

View File

@@ -2,66 +2,66 @@
* Uses platform CSS variables exclusively for theming.
*/
.tal-shell {
.ext-team-activity-log-shell {
max-width: 720px;
margin: 0 auto;
padding: 24px 16px;
padding: var(--sp-6) var(--sp-4);
display: flex;
flex-direction: column;
gap: 16px;
gap: var(--sp-4);
min-height: 100%;
}
/* ── Header ────────────────────────────────── */
.tal-header {
.ext-team-activity-log-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: var(--sp-3);
}
.tal-header__left {
.ext-team-activity-log-header__left {
display: flex;
align-items: baseline;
gap: 10px;
gap: var(--sp-3);
}
.tal-title {
.ext-team-activity-log-title {
font-size: 20px;
font-weight: 600;
color: var(--text);
margin: 0;
}
.tal-subtitle {
.ext-team-activity-log-subtitle {
font-size: 13px;
color: var(--text-3);
}
/* ── Entry Form ────────────────────────────── */
.tal-form {
.ext-team-activity-log-form {
display: flex;
gap: 8px;
gap: var(--sp-2);
background: var(--bg-surface);
border: 1px solid var(--border);
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);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-family: var(--font);
font-size: 13px;
padding: 6px 8px;
padding: var(--sp-2) var(--sp-2);
cursor: pointer;
}
.tal-form__input {
.ext-team-activity-log-form__input {
flex: 1;
background: var(--bg-raised);
border: 1px solid var(--border);
@@ -69,20 +69,20 @@
color: var(--text);
font-family: var(--font);
font-size: 14px;
padding: 6px 10px;
padding: var(--sp-2) var(--sp-3);
outline: none;
transition: border-color var(--transition);
}
.tal-form__input:focus {
.ext-team-activity-log-form__input:focus {
border-color: var(--accent);
}
.tal-form__input::placeholder {
.ext-team-activity-log-form__input::placeholder {
color: var(--text-3);
}
.tal-form__btn {
.ext-team-activity-log-form__btn {
background: var(--accent);
color: #fff;
border: none;
@@ -90,53 +90,53 @@
font-family: var(--font);
font-size: 13px;
font-weight: 500;
padding: 6px 16px;
padding: var(--sp-2) var(--sp-4);
cursor: pointer;
transition: background var(--transition);
}
.tal-form__btn:hover:not(:disabled) {
.ext-team-activity-log-form__btn:hover:not(:disabled) {
background: var(--accent-hover);
}
.tal-form__btn:disabled {
.ext-team-activity-log-form__btn:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Filters ───────────────────────────────── */
.tal-filters {
.ext-team-activity-log-filters {
display: flex;
align-items: center;
gap: 6px;
gap: var(--sp-2);
flex-wrap: wrap;
}
.tal-filter {
.ext-team-activity-log-filter {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-2);
font-family: var(--font);
font-size: 12px;
padding: 4px 10px;
padding: var(--sp-1) var(--sp-3);
cursor: pointer;
transition: all var(--transition);
}
.tal-filter:hover {
.ext-team-activity-log-filter:hover {
background: var(--bg-hover);
color: var(--text);
}
.tal-filter.active {
.ext-team-activity-log-filter.ext-team-activity-log-active {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
}
.tal-count {
.ext-team-activity-log-count {
margin-left: auto;
font-size: 12px;
color: var(--text-3);
@@ -144,70 +144,70 @@
/* ── Entry List ────────────────────────────── */
.tal-entries {
.ext-team-activity-log-entries {
display: flex;
flex-direction: column;
gap: 6px;
gap: var(--sp-2);
}
.tal-entry {
.ext-team-activity-log-entry {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
padding: var(--sp-3) var(--sp-3);
transition: border-color var(--transition);
}
.tal-entry:hover {
.ext-team-activity-log-entry:hover {
border-color: var(--border-light);
}
.tal-entry__header {
.ext-team-activity-log-entry__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
gap: var(--sp-2);
margin-bottom: var(--sp-1);
}
.tal-entry__category {
.ext-team-activity-log-entry__category {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.tal-entry__user {
.ext-team-activity-log-entry__user {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.tal-entry__time {
.ext-team-activity-log-entry__time {
font-size: 12px;
color: var(--text-3);
margin-left: auto;
}
.tal-entry__delete {
.ext-team-activity-log-entry__delete {
background: none;
border: none;
color: var(--text-3);
font-size: 16px;
cursor: pointer;
padding: 0 4px;
padding: 0 var(--sp-1);
line-height: 1;
opacity: 0;
transition: all var(--transition);
}
.tal-entry:hover .tal-entry__delete {
.ext-team-activity-log-entry:hover .ext-team-activity-log-entry__delete {
opacity: 1;
}
.tal-entry__delete:hover {
.ext-team-activity-log-entry__delete:hover {
color: var(--danger);
}
.tal-entry__message {
.ext-team-activity-log-entry__message {
font-size: 14px;
color: var(--text);
line-height: 1.5;
@@ -215,10 +215,10 @@
/* ── Empty / Loading ───────────────────────── */
.tal-empty,
.tal-loading {
.ext-team-activity-log-empty,
.ext-team-activity-log-loading {
text-align: center;
padding: 40px 16px;
padding: var(--sp-10) var(--sp-4);
color: var(--text-3);
font-size: 14px;
}

View File

@@ -104,34 +104,34 @@
}
return html`
<div class="tal-shell">
<header class="tal-header">
<div class="tal-header__left">
<h1 class="tal-title">Activity Log</h1>
<span class="tal-subtitle">Team activity tracker</span>
<div class="ext-team-activity-log-shell">
<header class="ext-team-activity-log-header">
<div class="ext-team-activity-log-header__left">
<h1 class="ext-team-activity-log-title">Activity Log</h1>
<span class="ext-team-activity-log-subtitle">Team activity tracker</span>
</div>
<div class="tal-header__right">
<div class="ext-team-activity-log-header__right">
<div ref=${menuRef}></div>
</div>
</header>
<${EntryForm} onSubmit=${addEntry} />
<div class="tal-filters">
<button class="tal-filter ${filter === '' ? 'active' : ''}"
<div class="ext-team-activity-log-filters">
<button class="ext-team-activity-log-filter ${filter === '' ? 'ext-team-activity-log-active' : ''}"
onClick=${() => setFilter('')}>All</button>
${CATEGORIES.map(c => html`
<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)}>
${c}
</button>
`)}
<span class="tal-count">${entries.length} entries</span>
<span class="ext-team-activity-log-count">${entries.length} entries</span>
</div>
${loading
? html`<div class="tal-loading">Loading…</div>`
? html`<div class="ext-team-activity-log-loading">Loading…</div>`
: html`<${EntryList} entries=${entries} onDelete=${deleteEntry} />`
}
</div>
@@ -153,17 +153,17 @@
}
return html`
<form class="tal-form" onSubmit=${submit}>
<select class="tal-form__select" value=${category}
<form class="ext-team-activity-log-form" onSubmit=${submit}>
<select class="ext-team-activity-log-form__select" value=${category}
onChange=${e => setCategory(e.target.value)}>
${CATEGORIES.map(c => html`<option key=${c} value=${c}>${c}</option>`)}
</select>
<input class="tal-form__input" type="text"
<input class="ext-team-activity-log-form__input" type="text"
placeholder="What happened?"
value=${message}
onInput=${e => setMessage(e.target.value)}
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'}
</button>
</form>
@@ -172,21 +172,21 @@
function EntryList({ entries, onDelete }) {
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`
<div class="tal-entries">
<div class="ext-team-activity-log-entries">
${entries.map(e => html`
<div key=${e.id} class="tal-entry">
<div class="tal-entry__header">
<span class="tal-entry__category badge">${e.category}</span>
<span class="tal-entry__user">${esc(e.username || 'unknown')}</span>
<span class="tal-entry__time">${timeAgo(e.created_at)}</span>
<button class="tal-entry__delete" onClick=${() => onDelete(e.id)}
<div key=${e.id} class="ext-team-activity-log-entry">
<div class="ext-team-activity-log-entry__header">
<span class="ext-team-activity-log-entry__category badge">${e.category}</span>
<span class="ext-team-activity-log-entry__user">${esc(e.username || 'Unknown')}</span>
<span class="ext-team-activity-log-entry__time">${timeAgo(e.created_at)}</span>
<button class="ext-team-activity-log-entry__delete" onClick=${() => onDelete(e.id)}
title="Delete">×</button>
</div>
<div class="tal-entry__message">${esc(e.message)}</div>
<div class="ext-team-activity-log-entry__message">${esc(e.message)}</div>
</div>
`)}
</div>

View File

@@ -4,22 +4,22 @@
defined in variables.css for automatic dark/light support.
========================================== */
.surface-workflow-demo {
.ext-workflow-demo {
max-width: 1100px;
margin: 0 auto;
padding: 0 1.5rem 2rem;
padding: 0 var(--sp-6) var(--sp-8);
}
/* Topbar */
.demo-topbar {
.ext-workflow-demo-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 0;
padding: var(--sp-4) 0;
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-weight: 700;
margin: 0;
@@ -27,65 +27,65 @@
}
/* Subtitle */
.demo-subtitle {
.ext-workflow-demo-subtitle {
color: var(--text-2);
margin: 0 0 1.5rem;
margin: 0 0 var(--sp-6);
font-size: 0.9rem;
}
/* Grid */
.demo-grid {
.ext-workflow-demo-grid {
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
gap: var(--sp-6);
}
@media (min-width: 768px) {
.demo-grid {
.ext-workflow-demo-grid {
grid-template-columns: 1fr 1fr;
}
}
/* Card */
.demo-card {
.ext-workflow-demo-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
padding: var(--sp-5);
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: var(--sp-3);
color: var(--text);
}
/* Title row */
.card-title-row {
.ext-workflow-demo-card-title-row {
display: flex;
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-weight: 600;
margin: 0;
flex: 1;
color: var(--text);
}
.card-icon {
.ext-workflow-demo-card-icon {
font-size: 1.3rem;
}
.card-tier {
.ext-workflow-demo-card-tier {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 2px 6px;
border-radius: 4px;
border-radius: var(--radius-sm);
font-weight: 600;
}
.badge-browser { background: var(--success-dim); color: var(--success-light); }
.badge-starlark { background: var(--accent-dim); color: var(--accent-light); }
.ext-workflow-demo-badge-browser { background: var(--success-dim); color: var(--success-light); }
.ext-workflow-demo-badge-starlark { background: var(--accent-dim); color: var(--accent-light); }
/* Description */
.card-desc {
.ext-workflow-demo-card-desc {
color: var(--text-2);
font-size: 0.85rem;
margin: 0;
@@ -93,38 +93,38 @@
}
/* Badges */
.card-badges {
.ext-workflow-demo-card-badges {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.demo-card .badge {
.ext-workflow-demo-card .ext-workflow-demo-badge {
font-size: 0.7rem;
padding: 2px 8px;
border-radius: 10px;
border-radius: var(--radius-lg);
background: var(--bg-raised);
color: var(--text-2);
white-space: nowrap;
}
.demo-card .badge-installed { background: var(--success-dim); color: var(--success-light); }
.demo-card .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-installed { background: var(--success-dim); color: var(--success-light); }
.ext-workflow-demo-card .ext-workflow-demo-badge-not-installed { background: var(--warning-dim); color: var(--warning-light); }
.ext-workflow-demo-card .ext-workflow-demo-badge-needs-publish { background: var(--danger-dim); color: var(--danger-light); }
/* Stage flow diagram */
.stage-flow {
.ext-workflow-demo-stage-flow {
display: flex;
align-items: flex-start;
gap: 0.25rem;
gap: var(--sp-1);
flex-wrap: wrap;
padding: 0.75rem;
padding: var(--sp-3);
background: var(--bg-raised);
border-radius: 6px;
border-radius: var(--radius);
overflow-x: auto;
}
.stage-node {
.ext-workflow-demo-stage-node {
border: 1px solid var(--border);
border-radius: 6px;
border-radius: var(--radius);
padding: 0.4rem 0.6rem;
min-width: 80px;
text-align: center;
@@ -132,31 +132,31 @@
font-size: 0.75rem;
color: var(--text);
}
.stage-node.stage-public {
.ext-workflow-demo-stage-node.ext-workflow-demo-stage-public {
border-color: var(--success);
background: var(--success-dim);
}
.stage-node.stage-system {
.ext-workflow-demo-stage-node.ext-workflow-demo-stage-system {
border-color: var(--accent);
background: var(--accent-dim);
}
.stage-label {
.ext-workflow-demo-stage-label {
font-weight: 600;
font-size: 0.8rem;
}
.stage-meta {
.ext-workflow-demo-stage-meta {
color: var(--text-3);
font-size: 0.65rem;
}
.stage-note {
.ext-workflow-demo-stage-note {
color: var(--warning-light);
font-size: 0.6rem;
font-style: italic;
margin-top: 2px;
}
.stage-arrow {
.ext-workflow-demo-stage-arrow {
display: flex;
align-items: center;
font-size: 1.1rem;
@@ -166,91 +166,81 @@
}
/* Collapsible sections */
.card-collapsible {
.ext-workflow-demo-card-collapsible {
border: 1px solid var(--border);
border-radius: 6px;
border-radius: var(--radius);
font-size: 0.8rem;
}
.card-collapsible summary {
padding: 0.5rem 0.75rem;
.ext-workflow-demo-card-collapsible summary {
padding: var(--sp-2) var(--sp-3);
cursor: pointer;
font-weight: 600;
user-select: none;
color: var(--text);
}
.card-collapsible summary:hover {
.ext-workflow-demo-card-collapsible summary:hover {
background: var(--bg-hover);
}
.collapsible-content {
padding: 0 0.75rem 0.75rem;
.ext-workflow-demo-collapsible-content {
padding: 0 var(--sp-3) var(--sp-3);
}
.collapsible-content pre {
.ext-workflow-demo-collapsible-content pre {
background: var(--bg-raised);
color: var(--text);
padding: 0.5rem;
border-radius: 4px;
padding: var(--sp-2);
border-radius: var(--radius-sm);
overflow-x: auto;
font-size: 0.72rem;
line-height: 1.4;
margin: 0.25rem 0;
margin: var(--sp-1) 0;
font-family: var(--mono);
}
/* Actions */
.card-actions {
.ext-workflow-demo-card-actions {
display: flex;
align-items: center;
gap: 0.5rem;
gap: var(--sp-2);
margin-top: auto;
padding-top: 0.5rem;
padding-top: var(--sp-2);
border-top: 1px solid var(--border);
}
.demo-card .btn {
.ext-workflow-demo-card .ext-workflow-demo-btn {
padding: 0.4rem 1rem;
border: none;
border-radius: 6px;
border-radius: var(--radius);
font-size: 0.8rem;
cursor: pointer;
font-weight: 500;
}
.demo-card .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;
}
/* demo-card buttons — uses kernel .sw-btn--primary */
/* Public link row */
.public-link-row {
.ext-workflow-demo-public-link-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
gap: var(--sp-2);
margin-top: var(--sp-1);
}
.public-link-row input {
.ext-workflow-demo-public-link-row input {
flex: 1;
font-size: 0.7rem;
padding: 4px 8px;
padding: var(--sp-1) var(--sp-2);
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 4px;
border-radius: var(--radius-sm);
color: var(--text);
font-family: var(--mono);
}
.public-link-row .btn-copy {
padding: 4px 10px;
.ext-workflow-demo-public-link-row .ext-workflow-demo-btn-copy {
padding: var(--sp-1) var(--sp-3);
font-size: 0.7rem;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 4px;
border-radius: var(--radius-sm);
color: var(--text-2);
cursor: pointer;
}
.public-link-row .btn-copy:hover {
.ext-workflow-demo-public-link-row .ext-workflow-demo-btn-copy:hover {
background: var(--bg-hover);
}

View File

@@ -103,25 +103,25 @@
if (!mount) return;
const surface = document.createElement('div');
surface.className = 'surface-workflow-demo';
surface.className = 'ext-workflow-demo';
mount.appendChild(surface);
// Topbar with user menu
const topbar = document.createElement('div');
topbar.className = 'demo-topbar';
topbar.className = 'ext-workflow-demo-topbar';
topbar.innerHTML = '<h1>Workflow Demo</h1>';
surface.appendChild(topbar);
sw.userMenu(topbar, { flyout: 'down' });
// Subtitle
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.';
surface.appendChild(subtitle);
// Workflow cards
const grid = document.createElement('div');
grid.className = 'demo-grid';
grid.className = 'ext-workflow-demo-grid';
surface.appendChild(grid);
// Fetch installed workflows to check status
@@ -145,28 +145,28 @@
function _buildCard(wf, installedWf) {
const card = document.createElement('div');
card.className = 'demo-card';
card.className = 'ext-workflow-demo-card';
// Title row
const titleRow = document.createElement('div');
titleRow.className = 'card-title-row';
titleRow.innerHTML = '<span class="card-icon">' + wf.icon + '</span>'
titleRow.className = 'ext-workflow-demo-card-title-row';
titleRow.innerHTML = '<span class="ext-workflow-demo-card-icon">' + wf.icon + '</span>'
+ '<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);
// Description
const desc = document.createElement('p');
desc.className = 'card-desc';
desc.className = 'ext-workflow-demo-card-desc';
desc.textContent = wf.description;
card.appendChild(desc);
// Badges
const badges = document.createElement('div');
badges.className = 'card-badges';
badges.className = 'ext-workflow-demo-card-badges';
for (const b of wf.badges) {
const span = document.createElement('span');
span.className = 'badge';
span.className = 'ext-workflow-demo-badge';
span.textContent = b;
badges.appendChild(span);
}
@@ -188,7 +188,7 @@
// Status + action
const actions = document.createElement('div');
actions.className = 'card-actions';
actions.className = 'ext-workflow-demo-card-actions';
if (installedWf) {
const isActive = installedWf.is_active;
@@ -199,24 +199,24 @@
// Installed badge
const status = document.createElement('span');
status.className = 'badge badge-installed';
status.className = 'ext-workflow-demo-badge ext-workflow-demo-badge-installed';
status.textContent = 'Installed';
actions.appendChild(status);
if (!isActive) {
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';
actions.appendChild(hint);
} else if (!isPublished) {
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';
actions.appendChild(hint);
}
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.disabled = !isReady;
if (!isReady) {
@@ -235,14 +235,14 @@
// Public link for public_link workflows
if (installedWf.entry_mode === 'public_link' && isReady) {
const linkRow = document.createElement('div');
linkRow.className = 'public-link-row';
linkRow.className = 'ext-workflow-demo-public-link-row';
const linkInput = document.createElement('input');
linkInput.type = 'text';
linkInput.readOnly = true;
linkInput.value = window.location.origin + base + '/api/v1/public/workflows/' + installedWf.id + '/start';
linkRow.appendChild(linkInput);
const copyBtn = document.createElement('button');
copyBtn.className = 'btn-copy';
copyBtn.className = 'ext-workflow-demo-btn-copy';
copyBtn.textContent = 'Copy';
copyBtn.onclick = function () {
navigator.clipboard.writeText(linkInput.value).then(function () {
@@ -255,7 +255,7 @@
}
} else {
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';
actions.appendChild(status);
}
@@ -268,7 +268,7 @@
function _buildStageDiagram(wf) {
const container = document.createElement('div');
container.className = 'stage-flow';
container.className = 'ext-workflow-demo-stage-flow';
const stages = wf.stages;
let hasBranch = false;
@@ -277,21 +277,21 @@
const s = stages[i];
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');
label.className = 'stage-label';
label.className = 'ext-workflow-demo-stage-label';
label.textContent = s.name;
node.appendChild(label);
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 + ')' : '');
node.appendChild(meta);
if (s.note) {
const note = document.createElement('div');
note.className = 'stage-note';
note.className = 'ext-workflow-demo-stage-note';
note.textContent = s.note;
node.appendChild(note);
}
@@ -301,13 +301,13 @@
// Arrow (except after last stage)
if (i < stages.length - 1 && !stages[i + 1].branch) {
const arrow = document.createElement('div');
arrow.className = 'stage-arrow';
arrow.className = 'ext-workflow-demo-stage-arrow';
arrow.textContent = '\u2192';
container.appendChild(arrow);
} else if (stages[i + 1] && stages[i + 1].branch) {
if (!hasBranch) {
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';
container.appendChild(split);
hasBranch = true;
@@ -322,14 +322,14 @@
function _buildCollapsible(title, innerHtml) {
const details = document.createElement('details');
details.className = 'card-collapsible';
details.className = 'ext-workflow-demo-card-collapsible';
const summary = document.createElement('summary');
summary.textContent = title;
details.appendChild(summary);
const content = document.createElement('div');
content.className = 'collapsible-content';
content.className = 'ext-workflow-demo-collapsible-content';
content.innerHTML = innerHtml;
details.appendChild(content);

View 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
View 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
View 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

View 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
View 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
View 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

View File

@@ -24,13 +24,17 @@ func (p *BuiltinProvider) SupportsRegistration() bool { return true }
func (p *BuiltinProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) {
var req struct {
Login string `json:"login" binding:"required"`
Password string `json:"password" binding:"required"`
Login string `json:"login" binding:"required"`
Password string `json:"password" binding:"required"`
KeepLogin bool `json:"keep_login"`
}
if err := c.ShouldBindJSON(&req); err != nil {
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)
if err != nil {
return nil, ErrInvalidCreds

View File

@@ -56,9 +56,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
revoked_at TIMESTAMPTZ
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
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);

View File

@@ -41,9 +41,11 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
revoked_at TEXT
expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
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);

View File

@@ -72,7 +72,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
tokens, err := h.generateTokens(result.User)
tokens, err := h.generateTokens(result.User, false)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
@@ -94,7 +94,13 @@ func (h *AuthHandler) Login(c *gin.Context) {
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 {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
@@ -113,22 +119,32 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
}
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 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
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)
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 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"})
return
}
tokens, err := h.generateTokens(user)
tokens, err := h.generateTokens(user, info.KeepLogin)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
@@ -159,6 +175,19 @@ func (h *AuthHandler) Logout(c *gin.Context) {
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 ──────────────────────────────
// 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
tokens, err := h.generateTokens(result.User)
tokens, err := h.generateTokens(result.User, false)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
@@ -286,8 +315,14 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
refreshToken, _ := tokens["refresh_token"].(string)
userJSON, _ := json.Marshal(tokens["user"])
// Set page-auth cookie too (for SSR middleware)
c.SetCookie("arm_token", accessToken, 900, "/", "", false, false)
// Set page-auth cookie too (for SSR middleware).
// 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
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)
}
func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
// Access token (15 min)
func (h *AuthHandler) generateTokens(user *models.User, keepLogin bool) (gin.H, error) {
sc := LoadSessionConfig(context.Background(), h.stores.GlobalConfig)
// Access token
accessClaims := Claims{
UserID: user.ID,
Email: user.Email,
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()),
ID: uuid.New().String(),
},
@@ -315,20 +352,26 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
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()
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)
}
return gin.H{
"access_token": accessString,
"refresh_token": refreshRaw,
"token_type": "Bearer",
"expires_in": 900,
"access_token": accessString,
"refresh_token": refreshRaw,
"token_type": "Bearer",
"expires_in": int(sc.AccessTokenTTL.Seconds()),
"refresh_expires_in": int(refreshTTL.Seconds()),
"user": gin.H{
"id": user.ID,
"username": user.Username,

View File

@@ -747,6 +747,10 @@ func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
if p.Type != "surface" && p.Type != "full" {
continue
}
// Welcome is a fallback surface, not a navigable destination
if p.ID == "welcome" {
continue
}
route, _ := p.Manifest["route"].(string)
icon, _ := p.Manifest["icon"].(string)
enabled = append(enabled, navSurface{

View File

@@ -22,18 +22,12 @@ import (
"armature/triggers"
)
// defaultBundledPackages is the curated set of packages installed by default.
// Other packages still ship in the Docker image but require BUNDLED_PACKAGES
// to be set explicitly (or "*" for all).
//
// Recommended production override: BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules
var defaultBundledPackages = map[string]bool{
"notes": true,
"chat": true,
"chat-core": true,
"mermaid-renderer": true,
"schedules": true,
}
// defaultBundledPackages is empty — fresh installs start bare.
// Use BUNDLED_PACKAGES env var to control what gets installed per environment:
// dev: BUNDLED_PACKAGES=*
// test: BUNDLED_PACKAGES=notes,chat,chat-core
// prod: BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules
var defaultBundledPackages = map[string]bool{}
// InstallBundledPackages scans bundledDir for .pkg archives and installs
// 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)
if len(allowed) > 0 {
// nil = install all ("*"), empty map = install nothing (empty default set)
if allowed != nil {
pkgName := strings.TrimSuffix(entry.Name(), ".pkg")
if !allowed[pkgName] {
filtered++

View File

@@ -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) {
stores := newTestStores(t)
ctx := context.Background()
@@ -449,7 +450,7 @@ func TestBundledInstall_DefaultAllowlist(t *testing.T) {
bundledDir := 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{
"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",
})
// Empty allowlist → curated defaults
// Empty allowlist → empty default set → nothing installed
InstallBundledPackages(bundledDir, packagesDir, "", stores, nil)
// "notes" is in the default set → should be installed
pkg, err := stores.Packages.Get(ctx, "notes")
if err != nil || pkg == nil {
t.Fatal("notes should be installed (in default set)")
pkg, _ := stores.Packages.Get(ctx, "notes")
if pkg != nil {
t.Fatal("notes should NOT be installed (default set is empty)")
}
// "regex-tester" is NOT in the default set → should be filtered
pkg, _ = stores.Packages.Get(ctx, "regex-tester")
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)")
}
}

View File

@@ -81,3 +81,38 @@ func (h *PresenceHandler) SearchUsers(c *gin.Context) {
}
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})
}

View 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))
}
}

View 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
}

View 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)
}
}

View File

@@ -444,6 +444,13 @@ func main() {
wfScanner.Start()
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 := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
@@ -467,8 +474,9 @@ func main() {
protected.POST("/presence/heartbeat", presence.Heartbeat)
protected.GET("/presence", presence.Query)
// User search
// User search & resolve
protected.GET("/users/search", presence.SearchUsers)
protected.GET("/users/resolve", presence.ResolveUsers)
// Workflows
wfH := handlers.NewWorkflowHandler(stores)

View File

@@ -142,9 +142,30 @@ func parseAndValidateJWT(tokenString string, jwtSecret string) (*Claims, bool) {
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
// 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 {
cookie, err := c.Cookie("arm_token")
if err != nil || cookie == "" {
@@ -152,7 +173,11 @@ func UserIDFromCookie(c *gin.Context, jwtSecret string) string {
}
claims, ok := parseAndValidateJWT(cookie, jwtSecret)
if !ok {
return ""
// Accept expired-but-signed token for optional user context
claims, ok = parseJWTIgnoringExpiry(cookie, jwtSecret)
if !ok {
return ""
}
}
return claims.UserID
}

View 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")
}
}

View File

@@ -50,10 +50,17 @@ func AuthOrRedirect(cfg *config.Config, users store.UserStore, cache *UserStatus
return
}
// Try strict validation first (token not expired).
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
if !ok {
redirectToLogin(c, loginPath)
return
// 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 {
redirectToLogin(c, loginPath)
return
}
}
if claims.UserID == "" {

View File

@@ -494,6 +494,10 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar
continue
}
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)
}

View File

@@ -176,6 +176,28 @@ func (e *Engine) surfacePath(ctx context.Context, id string) string {
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 /.
// The DefaultSurfaceRedirect handler will resolve to the appropriate surface.
func (e *Engine) disabledRedirect() gin.HandlerFunc {

View File

@@ -27,12 +27,9 @@
<link rel="stylesheet" href="{{.BasePath}}/surfaces/{{.Surface}}/css/main.css?v={{.Version}}">
{{end}}
<style>
:root {
--banner-h: 28px;
}
body { margin: 0; background: var(--bg); color: var(--text); display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
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); }
.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; }
</style>
<meta name="theme-color" content="#0e0e10" id="metaThemeColor">
@@ -52,7 +49,7 @@
} catch(e) {}
})();
// 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() {
try {
var p = JSON.parse(localStorage.getItem('sb-appearance') || '{}');
@@ -60,7 +57,7 @@
var s = p.scale / 100;
document.addEventListener('DOMContentLoaded', function() {
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) {}
@@ -69,9 +66,12 @@
</head>
<body data-surface="{{.Surface}}" data-base-path="{{.BasePath}}">
{{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}}
</div>
<script>
(function(){ var b=document.querySelector('.banner-top.active'); if(b) document.documentElement.style.setProperty('--banner-top-height',b.offsetHeight+'px'); })();
</script>
{{end}}
{{if .Message.Visible}}
@@ -101,9 +101,12 @@
{{end}}
{{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}}
</div>
<script>
(function(){ var b=document.querySelector('.banner-bottom.active'); if(b) document.documentElement.style.setProperty('--banner-bottom-height',b.offsetHeight+'px'); })();
</script>
{{end}}
<script nonce="{{.CSPNonce}}">

View File

@@ -16,7 +16,7 @@
{{if .Multiple}}multiple{{end}}
{{if .Accept}}accept="{{.Accept}}"{{end}}
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()">
Choose files…
</button>

View File

@@ -7,7 +7,6 @@
<title>Armature</title>
<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 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/sw-login.css?v={{.Version}}">
<style>

View File

@@ -13,6 +13,6 @@
{{define "surface-extension"}}
<div id="extension-surface" class="extension-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>
{{end}}

View File

@@ -18,6 +18,7 @@
background: var(--bg);
color: var(--text);
min-height: 100vh;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;

View File

@@ -13,7 +13,7 @@
* { margin: 0; padding: 0; box-sizing: border-box; }
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 {
padding: 16px 24px;

View File

@@ -1355,6 +1355,42 @@ paths:
items:
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
# ──────────────────────────────────────────────

View File

@@ -13,6 +13,13 @@ import (
// ErrSystemGroup is returned when attempting to delete a system-sourced group.
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
// =========================================
@@ -79,11 +86,13 @@ type UserStore interface {
ListActiveUserIDs(ctx context.Context) ([]string, error)
// 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)
GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*RefreshTokenInfo, error)
RevokeRefreshToken(ctx context.Context, tokenHash string) error
RevokeAllRefreshTokens(ctx context.Context, userID string) error
CleanExpiredTokens(ctx context.Context) error
UpdateRefreshTokenActivity(ctx context.Context, userID string) error
// ── CS1 additions ──
@@ -94,6 +103,10 @@ type UserStore interface {
// Excludes the calling user. Max 20 results.
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 ──
// CountAll returns the total number of users.
@@ -123,6 +136,7 @@ type UserSearchResult struct {
Username string `json:"username"`
DisplayName string `json:"display_name"`
Handle string `json:"handle"`
AvatarURL string `json:"avatar_url,omitempty"`
}
// =========================================

View File

@@ -142,8 +142,10 @@ func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
// ── Refresh Tokens ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
_, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`, userID, tokenHash, expiresAt)
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, keep_login, last_activity_at) VALUES ($1, $2, $3, $4, NOW())`,
userID, tokenHash, expiresAt, keepLogin)
return err
}
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)
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 {
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash)
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'")
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 ────────────────────────────────
@@ -231,6 +250,47 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
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 ─────────────────────────────────────────────
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {

View File

@@ -148,9 +148,10 @@ func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
// ── Refresh Tokens ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
_, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)`,
store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt))
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, keep_login, last_activity_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt), keepLogin)
return err
}
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)
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 {
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash)
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')")
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 ────────────────────────────────
@@ -238,6 +262,47 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
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 ─────────────────────────────────────────────
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {

View File

@@ -8,9 +8,9 @@
display: flex;
flex-direction: column;
gap: 1px;
background: var(--border, #2a2a2e);
border: 1px solid var(--border, #2a2a2e);
border-radius: 8px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
@@ -20,7 +20,7 @@
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
background: var(--bg-secondary, #151517);
background: var(--bg-secondary);
}
.admin-surface-row:hover {
@@ -42,31 +42,31 @@
.admin-surface-name {
font-size: 14px;
font-weight: 600;
color: var(--text, #eee);
color: var(--text);
}
.admin-surface-badge {
font-size: 10px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
border-radius: var(--radius-sm);
text-transform: uppercase;
letter-spacing: 0.3px;
}
.admin-surface-badge--core {
background: var(--accent-dim, rgba(179, 138, 78, 0.15));
color: var(--accent, #b38a4e);
background: var(--accent-dim);
color: var(--accent);
}
.admin-surface-badge--extension {
background: var(--purple-dim, rgba(160, 120, 255, 0.1));
color: var(--purple, #a078ff);
background: var(--purple-dim);
color: var(--purple);
}
.admin-surface-badge--required {
background: var(--bg-raised, #2a2a2e);
color: var(--text-3, #555);
background: var(--bg-raised);
color: var(--text-3);
}
.admin-surface-meta {
@@ -74,18 +74,18 @@
align-items: center;
gap: 12px;
font-size: 12px;
color: var(--text-3, #555);
color: var(--text-3);
}
.admin-surface-id {
font-family: var(--mono, 'SF Mono', monospace);
font-family: var(--mono);
font-size: 11px;
}
.admin-surface-route {
font-family: var(--mono, 'SF Mono', monospace);
font-family: var(--mono);
font-size: 11px;
color: var(--text-3, #555);
color: var(--text-3);
}
.admin-surface-actions {
@@ -114,8 +114,8 @@
height: 20px;
appearance: none;
-webkit-appearance: none;
background: var(--bg-raised, #2a2a2e);
border-radius: 10px;
background: var(--bg-raised);
border-radius: var(--radius-lg);
position: relative;
cursor: pointer;
transition: background 0.2s;
@@ -123,7 +123,7 @@
}
.admin-surface-toggle input[type="checkbox"]:checked {
background: var(--accent, #b38a4e);
background: var(--accent);
}
.admin-surface-toggle input[type="checkbox"]::after {
@@ -148,7 +148,7 @@
.admin-surface-toggle-label {
font-size: 12px;
color: var(--text-2, #999);
color: var(--text-2);
min-width: 56px;
}
@@ -159,36 +159,36 @@
gap: 12px;
}
.dashboard-provider-card {
background: var(--bg-surface, #1a1a1d);
border: 1px solid var(--border, #2a2a2e);
border-radius: 8px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 14px;
}
.dashboard-db-pool {
background: var(--bg-surface, #1a1a1d);
border: 1px solid var(--border, #2a2a2e);
border-radius: 8px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
}
.dashboard-pool-bar {
height: 8px;
background: var(--bg-raised, #2a2a2e);
border-radius: 4px;
background: var(--bg-raised);
border-radius: var(--radius-sm);
overflow: hidden;
}
.dashboard-pool-fill {
height: 100%;
background: var(--accent, #b38a4e);
border-radius: 4px;
background: var(--accent);
border-radius: var(--radius-sm);
transition: width 0.3s ease;
}
.dashboard-errors {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--border, #2a2a2e);
border: 1px solid var(--border, #2a2a2e);
border-radius: 8px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.dashboard-error-row {
@@ -196,6 +196,6 @@
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--bg-secondary, #151517);
background: var(--bg-secondary);
font-size: 12px;
}

View File

@@ -70,14 +70,14 @@
.sidebar-top {
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);
}
/* Brand / collapse toggle */
.sb-brand {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: var(--radius);
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
background: none; border: none; color: var(--text);
cursor: pointer; font-size: 13px; white-space: nowrap;
transition: background var(--transition); width: 100%;
@@ -85,13 +85,13 @@
}
.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 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-text { font-weight: 600; font-size: 14px; }
.sb-btn {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: var(--radius);
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
background: none; border: none; color: var(--text-2);
cursor: pointer; font-size: 13px; white-space: nowrap;
transition: background var(--transition), color var(--transition);
@@ -106,19 +106,19 @@
.sidebar.collapsed .brand-text { opacity: 0; width: 0; }
/* Collapsed centering */
.sidebar.collapsed .sidebar-top { padding: 10px 0 8px; align-items: center; }
.sidebar.collapsed .sidebar-bottom { padding: 8px 0; display: flex; flex-direction: column; align-items: center; }
.sidebar.collapsed .sb-brand { justify-content: center; padding: 8px 0; gap: 0; }
.sidebar.collapsed .sb-btn { justify-content: center; padding: 8px 0; gap: 0; }
.sidebar.collapsed .split-btn-main { justify-content: center; padding: 8px 0; gap: 0; }
.sidebar.collapsed .user-btn { justify-content: center; padding: 6px 0; gap: 0; }
.sidebar.collapsed .sidebar-top { padding: var(--sp-3) 0 var(--sp-2); 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: var(--sp-2) 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: var(--sp-2) 0; gap: 0; }
.sidebar.collapsed .user-btn { justify-content: center; padding: var(--sp-2) 0; gap: 0; }
.sidebar.collapsed .avatar-bug { display: none; }
/* Split button (New Chat + dropdown) */
.split-btn { display: flex; width: 100%; gap: 0; position: relative; }
.split-btn-main {
flex: 1; display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: var(--radius) 0 0 var(--radius);
flex: 1; display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius) 0 0 var(--radius);
background: none; border: none; color: var(--text-2);
cursor: pointer; font-size: 13px; white-space: nowrap;
transition: background var(--transition), color var(--transition);
@@ -127,7 +127,7 @@
.split-btn-main svg { flex-shrink: 0; }
.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;
background: none; border: none; border-left: 1px solid var(--border);
color: var(--text-3); cursor: pointer;
@@ -141,13 +141,13 @@
display: none; position: absolute; left: 6px; right: 6px;
top: 100%; z-index: 200;
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);
}
.split-dropdown.open { display: block; }
.split-dropdown-item {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px; border-radius: 4px; width: 100%;
display: flex; align-items: center; gap: var(--sp-2);
padding: 7px var(--sp-3); border-radius: var(--radius-sm); width: 100%;
background: none; border: none; color: var(--text-2);
cursor: pointer; font-size: 12px; white-space: nowrap;
transition: background var(--transition), color var(--transition);
@@ -160,8 +160,8 @@
.user-btn {
display: flex; align-items: center; gap: 10px;
padding: 6px 8px; border-radius: var(--radius);
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-2) var(--sp-2); border-radius: var(--radius);
background: none; border: none; color: var(--text);
cursor: pointer; width: 100%;
transition: background var(--transition);
@@ -198,12 +198,12 @@
/* ── Sidebar Tabs (v0.21.6) ──────────── */
.sidebar-tabs {
display: flex; padding: 0 8px; gap: 0;
display: flex; padding: 0 var(--sp-2); gap: 0;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.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;
color: var(--text-3); cursor: pointer;
transition: color 0.15s, border-color 0.15s;
@@ -219,7 +219,7 @@
.sidebar-search {
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);
border-radius: var(--radius); transition: border-color var(--transition);
}
@@ -233,7 +233,7 @@
.sidebar-search input::placeholder { color: var(--text-3); }
.sidebar-search-clear {
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;
}
.sidebar-search-clear:hover { color: var(--text); }
@@ -251,7 +251,7 @@
.mobile-menu-btn {
display: none; align-items: center; justify-content: center;
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);
}
.mobile-menu-btn:hover { color: var(--text); }
@@ -260,6 +260,11 @@
background: var(--overlay);
}
@media (max-width: 1024px) {
.workspace-secondary.open { width: 360px; min-width: 360px; }
.admin-sidebar { width: 120px; }
}
@media (max-width: 768px) {
.sidebar { position: fixed; z-index: 100; height: 100%; transition: width 0.2s ease; }
.sidebar.collapsed { width: 0; border: none; overflow: hidden; }

View File

@@ -8,7 +8,7 @@
position: fixed; inset: 0; background: var(--overlay);
display: none; align-items: center; justify-content: center;
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; }
@@ -23,25 +23,25 @@
.modal-header {
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-close {
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);
}
.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 {
padding: 12px 20px; border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: 0.5rem;
padding: var(--sp-3) var(--sp-5); border-top: 1px solid var(--border);
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); }
.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-upload-row { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-4); }
.avatar-preview {
width: 56px; height: 56px; border-radius: 50%; background: var(--bg-raised);
display: flex; align-items: center; justify-content: center;
@@ -49,7 +49,7 @@
overflow: hidden; border: 2px solid var(--border);
}
.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-preview-sm { width: 42px; height: 42px; font-size: 18px; }
@@ -59,7 +59,7 @@
border-bottom: 1px solid var(--border); background: var(--bg-raised);
}
.modal-tabs {
display: flex; padding: 0 16px;
display: flex; padding: 0 var(--sp-4);
border-bottom: 1px solid var(--border); background: var(--bg-raised);
overflow-x: auto; overflow-y: hidden;
scrollbar-width: none; -ms-overflow-style: none;
@@ -86,7 +86,7 @@
/* Settings tabs */
.settings-tabs { gap: 0; }
.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);
border-bottom: 2px solid transparent; transition: color var(--transition);
outline: none; -webkit-appearance: none; appearance: none;
@@ -97,19 +97,19 @@
.settings-notice {
background: rgba(234,179,8,0.08); border: 1px solid var(--warning-dim);
border-radius: var(--radius); padding: 10px 14px; font-size: 13px;
color: var(--warning); display: flex; align-items: center; gap: 8px;
border-radius: var(--radius); padding: var(--sp-3) var(--sp-3); font-size: 13px;
color: var(--warning); display: flex; align-items: center; gap: var(--sp-2);
margin-top: 1rem;
}
/* Providers */
.provider-row {
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-meta { font-size: 11px; color: var(--text-3); margin-left: 8px; }
.provider-actions { display: flex; align-items: center; gap: 8px; }
.provider-meta { font-size: 11px; color: var(--text-3); margin-left: var(--sp-2); }
.provider-actions { display: flex; align-items: center; gap: var(--sp-2); }
/* Admin panel (fullscreen) */
.admin-panel {
@@ -124,7 +124,7 @@
}
.admin-back {
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-title { font-size: 16px; font-weight: 600; margin-left: 1rem; flex: 1; }
@@ -147,18 +147,18 @@
.admin-sidebar {
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 {
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;
transition: color 0.1s, background 0.1s;
}
.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-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 shared */
@@ -172,14 +172,14 @@
.admin-tab:hover { color: var(--text-2); }
.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-inline-form {
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 {
background: var(--bg); padding: 12px 0;
background: var(--bg); padding: var(--sp-3) 0;
}
.ext-edit-form textarea {
resize: vertical; min-height: 60px;
@@ -194,17 +194,17 @@
.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-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 .btn-danger { color: var(--danger); }
.admin-user-actions .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; }
.admin-user-actions .sw-btn--danger { 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: 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-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-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.enabled { border-color: var(--success); color: var(--success); }
.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; }
.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;
}
.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: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; }
.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; }
@@ -233,10 +233,10 @@
.admin-persona-row .btn-delete:hover { color: var(--danger); }
/* 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 {
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-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-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; }
.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; }
.team-card { padding: 8px 10px; background: var(--glass); border-radius: 6px; margin-bottom: 6px; }
.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: var(--radius); margin-bottom: 6px; }
.team-card-info { display: flex; align-items: center; gap: 8px; }
.team-admin-back {
cursor: pointer; margin-right: 4px; opacity: 0.5;
@@ -297,9 +297,9 @@
.team-tab-content { padding: 0; }
/* Health + Routing admin (v0.22.3) */
.admin-health-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
.admin-health-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
.list-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 8px; }
.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: var(--radius); padding: var(--sp-3); }
.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; }
@@ -324,8 +324,8 @@
}
.cmd-input-wrap {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: var(--sp-3);
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 input {
@@ -336,17 +336,17 @@
.cmd-kbd {
font-size: 10px; font-family: var(--mono); color: var(--text-3);
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 {
max-height: 320px; overflow-y: auto;
padding: 4px;
padding: var(--sp-1);
}
.cmd-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px; border-radius: var(--radius);
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
cursor: pointer; font-size: 13px; color: var(--text-2);
transition: background 60ms ease, color 60ms ease;
}
@@ -362,18 +362,18 @@
.cmd-item.active .cmd-item-hint { color: var(--accent); }
.cmd-group-label {
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 {
display: flex; align-items: center; gap: 16px;
padding: 8px 16px; border-top: 1px solid var(--border);
display: flex; align-items: center; gap: var(--sp-4);
padding: var(--sp-2) var(--sp-4); border-top: 1px solid var(--border);
font-size: 11px; color: var(--text-3);
}
.cmd-footer kbd {
font-size: 10px; font-family: var(--mono);
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;
}
.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 {
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;
}
.confirm-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 0 20px 16px;
display: flex; justify-content: flex-end; gap: var(--sp-2);
padding: 0 var(--sp-5) var(--sp-4);
}
@keyframes confirm-in { from { opacity: 0; transform: scale(0.95); } }
@keyframes fade-in { from { opacity: 0; } }
@@ -409,7 +409,7 @@
/* ── Group Chat persona picker (v0.23.0) ────── */
.group-persona-item {
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;
transition: background 0.1s;
}
@@ -429,21 +429,21 @@
.sb-dialog {
background: var(--bg-raised); border: 1px solid var(--border-light);
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;
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);
animation: confirm-in 0.15s ease;
}
.sb-dialog-title { font-size: 14px; font-weight: 600; color: var(--text); }
.sb-dialog-input {
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-family: inherit; outline: none; width: 100%; box-sizing: border-box;
}
.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-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); }

View File

@@ -1,27 +1,11 @@
/* ── 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 ─────────────────────────────── */
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 ────────────────────────────────── */
@@ -31,7 +15,7 @@ button { font-family: var(--font); cursor: pointer; }
select, input[type="text"], input[type="email"], input[type="password"],
input[type="number"], input[type="search"], textarea {
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;
transition: border-color var(--transition);
}
@@ -41,12 +25,12 @@ input[type="password"]:focus, textarea:focus {
}
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-hint { font-weight: 400; color: var(--text-3); margin-left: 4px; }
.range-input {
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;
}
.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); }
/* ── 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); } }
/* Toast system — see sw-primitives.css (.sw-toast) */
/* ── Mode Selector (v0.21.3) ───────────── */
@@ -86,7 +57,7 @@ select option { background: var(--bg-surface); color: var(--text); }
}
.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);
flex-shrink: 0;
}
@@ -110,16 +81,16 @@ select option { background: var(--bg-surface); color: var(--text); }
/* ── REPL Console (v0.21.3) ────────────── */
.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;
}
.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;
}
.repl-input-wrap {
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);
}
.repl-prompt {
@@ -136,7 +107,7 @@ select option { background: var(--bg-surface); color: var(--text); }
.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-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-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); }
/* ── Popup Menu (shared base) ───────────── */
.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; }
/* Popup menu — see sw-primitives.css (.sw-menu) */
/* ── Tools Toggle ────────────────────────── */
@@ -200,7 +149,7 @@ select option { background: var(--bg-surface); color: var(--text); }
.popup {
display: none; position: absolute;
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;
}
.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-category, .tools-popup-tool {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px; border-radius: var(--radius);
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
cursor: pointer; font-size: 13px; color: var(--text-2);
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;
}
.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;
}
.kb-popup-item {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; cursor: pointer;
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3); cursor: pointer;
transition: background var(--transition);
}
.kb-popup-item:hover { background: var(--bg-hover); }
.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;
display: flex; align-items: center; justify-content: center;
transition: all var(--transition);
@@ -324,7 +273,7 @@ select option { background: var(--bg-surface); color: var(--text); }
/* ── Shared Form Controls ────────────────── */
.form-group { margin-bottom: 14px; }
.form-group { margin-bottom: var(--sp-3); }
.form-group label {
display: block; font-size: 12px; font-weight: 500;
color: var(--text-2); margin-bottom: 5px;
@@ -342,11 +291,11 @@ select option { background: var(--bg-surface); color: var(--text); }
.toggle-group {
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;
}
.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);
cursor: pointer; font-size: 12px; font-weight: 500;
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:hover { color: var(--text-2); }
.btn-ghost { background: transparent; color: var(--text-2); border: 1px solid var(--border); }
.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; }
/* Button variants — see sw-primitives.css (.sw-btn--*) */
/* ── 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;
display: inline-flex; align-items: center;
}
@@ -391,7 +336,7 @@ select option { background: var(--bg-surface); color: var(--text); }
/* ── Icon Button ─────────────────────────── */
.icon-btn {
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;
transition: all 0.15s;
}
@@ -400,9 +345,9 @@ select option { background: var(--bg-surface); color: var(--text); }
.icon-btn-danger:hover { color: var(--danger); }
/* ── Utility States ─────────────────────── */
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }
.empty-hint { color: var(--text-3); font-size: 13px; text-align: center; padding: 1rem; }
.loading { color: var(--text-3); font-size: 13px; padding: var(--sp-2); }
.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: var(--sp-4); }
/* ── 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-flyout {
display: none; position: absolute; right: 0; left: auto; z-index: 200;
background: var(--bg-elevated, #42424e); border: 1px solid var(--border-elevated, #555);
border-radius: var(--radius-lg, 8px); padding: 4px; min-width: 170px;
background: var(--bg-elevated); border: 1px solid var(--border-elevated);
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);
}
.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; }
/* Flyout items — single source of truth for all flyout menus (sw.menu + UserMenu) */
.sw-menu-flyout .flyout-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px; border-radius: var(--radius, 6px);
background: none; border: none; color: var(--text, #e8e8ed);
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
background: none; border: none; color: var(--text);
cursor: pointer; font-size: 13px; font-family: inherit; width: 100%;
transition: background 0.15s, color 0.15s;
}
.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-danger { color: var(--danger, #f44336); }
.sw-menu-flyout .flyout-danger { color: var(--danger); }
.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 {
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 */
.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); }
/* Dropdown — see sw-primitives.css (.sw-dropdown) */
/* Toolbar — horizontal button row */
.sw-toolbar {
display: flex; align-items: center; gap: 4px;
padding: 4px 0;
display: flex; align-items: center; gap: var(--sp-1);
padding: var(--sp-1) 0;
}
/* Tabs — tabbed content container */
.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; }
/* Tabs — see sw-primitives.css (.sw-tabs) */
/* ── Responsive: Mobile ───────────────────── */
@media (max-width: 768px) {
/* Flyout items: larger touch targets */
.sw-menu-flyout .flyout-item {
padding: 10px 12px;
padding: var(--sp-3) var(--sp-3);
min-height: 44px;
}
@@ -491,6 +413,7 @@ select option { background: var(--bg-surface); color: var(--text); }
width: calc(100vw - 32px);
max-width: 100%;
max-height: calc(100vh - 64px);
max-height: calc(100dvh - 64px);
}
/* Toast: full width, centered */
@@ -507,17 +430,17 @@ select option { background: var(--bg-surface); color: var(--text); }
.sw-user-picker__input {
width: 100%;
padding: 8px 10px;
border-radius: var(--radius, 6px);
border: 1px solid var(--border, #2a2a2e);
background: var(--bg, #0e0e10);
color: var(--text, #eee);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 0.85em;
}
.sw-user-picker__input:focus {
outline: none;
border-color: var(--accent, #b38a4e);
border-color: var(--accent);
}
.sw-user-picker__spinner {
@@ -527,8 +450,8 @@ select option { background: var(--bg-surface); color: var(--text); }
transform: translateY(-50%);
width: 14px;
height: 14px;
border: 2px solid var(--border, #2a2a2e);
border-top-color: var(--accent, #b38a4e);
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: sw-spin 0.6s linear infinite;
}
@@ -541,9 +464,9 @@ select option { background: var(--bg-surface); color: var(--text); }
left: 0;
right: 0;
margin-top: 4px;
background: var(--bg-elevated, #2a2a2e);
border: 1px solid var(--border, #2a2a2e);
border-radius: var(--radius, 6px);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
z-index: 300;
max-height: 240px;
@@ -553,22 +476,22 @@ select option { background: var(--bg-surface); color: var(--text); }
.sw-user-picker__option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
cursor: pointer;
transition: background 0.1s;
}
.sw-user-picker__option:hover,
.sw-user-picker__option--active {
background: var(--bg-hover, rgba(255,255,255,0.06));
background: var(--bg-hover);
}
.sw-user-picker__avatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--accent, #b38a4e);
background: var(--accent);
color: #fff;
display: flex;
align-items: center;
@@ -586,11 +509,11 @@ select option { background: var(--bg-surface); color: var(--text); }
.sw-user-picker__name {
font-size: 0.85em;
color: var(--text, #eee);
color: var(--text);
}
.sw-user-picker__handle {
font-size: 0.75em;
color: var(--text-muted, #888);
color: var(--text-3);
}

View File

@@ -6,13 +6,13 @@
.admin-storage-card {
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-desc { font-size: 12px; color: var(--text-3); margin: 0 0 10px 0; }
.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 var(--sp-3) 0; }
.admin-storage-grid {
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-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;
}
.settings-topbar {
display: flex; align-items: center; gap: 12px;
padding: 10px 20px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-3) var(--sp-5); border-bottom: 1px solid var(--border);
background: var(--bg-surface); flex-shrink: 0;
}
.settings-topbar-back {
display: flex; align-items: center; gap: 8px;
background: none; border: none; color: var(--text-3);
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-sep { width: 1px; height: 20px; background: var(--border); }
@@ -48,12 +48,12 @@
width: 220px; flex-shrink: 0;
border-right: 1px solid var(--border);
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;
}
.settings-nav-link {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; border-radius: 8px;
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
color: var(--text-2); text-decoration: none;
font-size: 13px; font-weight: 500; transition: all 0.12s;
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.active { background: var(--accent-dim); color: var(--accent); }
.settings-nav-sep { margin: 8px 0; border-top: 1px solid var(--border); }
.settings-content { flex: 1; overflow-y: auto; padding: 28px; }
.settings-content h2 { font-size: 18px; font-weight: 600; margin: 0 0 20px; }
.settings-nav-sep { margin: var(--sp-2) 0; border-top: 1px solid var(--border); }
.settings-content { flex: 1; overflow-y: auto; padding: var(--sp-8); }
.settings-content h2 { font-size: 18px; font-weight: 600; margin: 0 0 var(--sp-5); }
.settings-content .settings-section {
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 {
font-size: 14px; font-weight: 600; margin: 0 0 14px;
padding-bottom: 8px; border-bottom: 1px solid var(--border);
font-size: 14px; font-weight: 600; margin: 0 0 var(--sp-3);
padding-bottom: var(--sp-2); border-bottom: 1px solid var(--border);
}
.settings-section {
margin-bottom: 24px; padding: 20px;
background: var(--bg-surface); border-radius: 10px;
margin-bottom: var(--sp-6); padding: var(--sp-5);
background: var(--bg-surface); border-radius: var(--radius-lg);
border: 1px solid var(--border);
}
.settings-section h3 {
font-size: 15px; font-weight: 600; margin: 0 0 16px;
padding-bottom: 10px; border-bottom: 1px solid var(--border);
font-size: 15px; font-weight: 600; margin: 0 0 var(--sp-4);
padding-bottom: var(--sp-3); border-bottom: 1px solid var(--border);
}
.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;
}
@@ -92,27 +92,27 @@
display: flex; flex-direction: column; height: 100%; overflow: hidden;
}
.admin-topbar {
display: flex; align-items: center; gap: 12px;
padding: 10px 20px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-3) var(--sp-5); border-bottom: 1px solid var(--border);
background: var(--bg-surface); flex-shrink: 0;
}
.admin-topbar-back {
display: flex; align-items: center; gap: 8px;
background: none; border: none; color: var(--text-3);
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-sep { width: 1px; height: 20px; background: var(--border); }
.admin-topbar-title { font-size: 15px; font-weight: 600; }
.admin-category-tabs {
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;
}
.admin-cat-btn {
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;
font-family: inherit; background: transparent; color: var(--text-3);
transition: all 0.12s;
@@ -123,10 +123,10 @@
.admin-nav {
width: 200px; background: var(--bg-surface);
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 {
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;
font-size: 13px; font-weight: 500; width: 100%;
text-align: left; transition: all 0.12s;
@@ -137,30 +137,30 @@
.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-header {
display: flex; align-items: center; gap: 12px;
padding: 14px 24px; border-bottom: 1px solid var(--border); flex-shrink: 0;
display: flex; align-items: center; gap: var(--sp-3);
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-search {
display: flex; align-items: center; gap: 7px;
background: var(--input-bg, var(--bg)); border-radius: 8px;
padding: 6px 10px; border: 1px solid var(--border); width: 220px;
background: var(--input-bg, var(--bg)); border-radius: var(--radius);
padding: var(--sp-2) var(--sp-3); border: 1px solid var(--border); width: 220px;
}
.admin-search input {
background: none; border: none; color: var(--text);
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-table { width: 100%; border-collapse: collapse; }
.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;
letter-spacing: 0.5px; border-bottom: 1px solid var(--border);
}
.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);
}
.data-table tr:hover td { background: var(--bg-hover); }
@@ -171,15 +171,15 @@
display: flex; flex-direction: column; height: 100%; overflow: hidden;
}
.editor-topbar {
display: flex; align-items: center; gap: 8px;
padding: 6px 12px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border);
background: var(--bg-surface); flex-shrink: 0;
}
.editor-topbar-back {
display: flex; align-items: center; gap: 8px;
background: none; border: none; color: var(--text-3);
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-body { display: flex; flex: 1; min-height: 0; }
@@ -189,10 +189,10 @@
display: flex; flex-direction: column; flex-shrink: 0;
}
.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;
}
.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-tabs {
display: flex; align-items: center;
@@ -201,7 +201,7 @@
}
.editor-tab {
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);
background: transparent; color: var(--text-3);
border-bottom: 2px solid transparent;
@@ -214,27 +214,27 @@
}
.editor-content { flex: 1; overflow: auto; }
.editor-statusbar {
display: flex; align-items: center; gap: 14px;
padding: 3px 12px; background: var(--bg-surface);
display: flex; align-items: center; gap: var(--sp-3);
padding: 3px var(--sp-3); background: var(--bg-surface);
border-top: 1px solid var(--border); flex-shrink: 0;
font-size: 11px; color: var(--text-3);
font-family: var(--mono);
}
/* ── 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 {
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-sub { font-size: 11px; color: var(--text-3); margin-top: 4px; }
/* ── 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-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-fill { position: absolute; bottom: 0; left: 0; right: 0; background: var(--accent); border-radius: 4px; transition: height 0.3s ease; }
.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: var(--radius-sm); transition: height 0.3s ease; }
.bar-chart-label { font-size: 10px; color: var(--text-3); }
/* ── Admin Settings Form (flat sections, prototype match) ── */
@@ -250,11 +250,11 @@
.admin-settings-form .toggle-label { margin-bottom: 8px; }
/* ── 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-track {
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 {
content: ''; position: absolute; top: 2px; left: 2px;
@@ -288,25 +288,31 @@
.admin-user-name { font-weight: 600; font-size: 13px; }
.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-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 { 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 {
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px;
padding: 16px; cursor: pointer; transition: border-color var(--transition), box-shadow var(--transition);
background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius);
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-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-meta { display: flex; gap: 6px; align-items: center; margin-top: 4px; flex-wrap: wrap; }
@media (max-width: 768px) {
.settings-nav { width: 160px; font-size: 12px; padding: 12px 8px; }
.settings-content { padding: 16px; }
@media (max-width: 1024px) {
.settings-nav { width: 180px; }
.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; }
}
@@ -322,7 +328,7 @@
}
.sidebar-nav::-webkit-scrollbar { width: 4px; }
.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 */
.sb-section { margin-bottom: 2px; }
@@ -364,7 +370,7 @@
color: var(--text-3);
cursor: pointer;
padding: 2px;
border-radius: 4px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
transition: opacity 0.1s, color 0.1s;
@@ -394,7 +400,7 @@
gap: 5px;
padding: 5px 10px;
cursor: pointer;
border-radius: 6px;
border-radius: var(--radius);
margin: 0 4px;
transition: background 0.1s;
}
@@ -446,7 +452,7 @@
font-size: 14px;
padding: 0 2px;
line-height: 1;
border-radius: 4px;
border-radius: var(--radius-sm);
transition: opacity 0.1s;
}
.sb-proj-header:hover .sb-proj-menu { opacity: 1; }
@@ -480,7 +486,7 @@
align-items: center;
gap: 7px;
padding: 5px 12px;
border-radius: 6px;
border-radius: var(--radius);
margin: 0 4px;
cursor: pointer;
font-size: 13px;
@@ -494,7 +500,7 @@
.sb-ch-menu {
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-channel-item:hover .sb-ch-menu { visibility: visible; }
@@ -509,14 +515,14 @@
background: var(--text-3);
flex-shrink: 0;
}
.sb-presence.online { background: var(--success-light, #22c55e); }
.sb-presence.online { background: var(--success-light); }
.sb-unread {
font-size: 10px;
font-weight: 700;
background: var(--accent);
color: #fff;
border-radius: 10px;
border-radius: var(--radius-lg);
padding: 1px 5px;
min-width: 16px;
text-align: center;

View File

@@ -31,7 +31,7 @@
.debug-check { display: flex; align-items: center; gap: 4px; color: var(--text-3); cursor: pointer; }
.debug-badge {
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 {
flex: 1; overflow-y: auto; padding: 6px;
@@ -42,7 +42,7 @@
.debug-pre {
white-space: pre-wrap; word-break: break-all;
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 ─────────────────────────── */

View File

@@ -4,7 +4,7 @@
──────────────────────────────────────────── */
/* ── 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; }
/* ── Hero Panel (left) ─────────────────────── */
@@ -66,7 +66,7 @@
/* ── Auth Panel (right) ────────────────────── */
.login-auth-panel {
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;
}
.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:hover { color: var(--text-2); }
.login-form-field { margin-bottom: 16px; }
.login-form-field { margin-bottom: var(--sp-4); }
.login-form-field label {
display: block; font-size: 13px; font-weight: 500;
color: var(--text-2); margin-bottom: 6px;
@@ -92,13 +92,20 @@
.login-form-field input {
width: 100%; box-sizing: border-box; background: var(--input-bg);
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;
}
.login-form-field input:focus {
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 {
color: var(--danger); font-size: 0.78rem; margin: 0 0 0.5rem;
}
@@ -106,7 +113,7 @@
.login-btn {
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;
transition: all 0.18s; margin-top: 1.5rem;
}
@@ -151,7 +158,7 @@
.login-anim-a3 { animation-delay: .29s; }
/* ── Responsive ─────────────────────────────── */
@media (max-width: 900px) {
@media (max-width: 1024px) {
.login-hero { display: none; }
.login-auth-panel { width: 100%; border-left: none; }
}

View File

@@ -13,9 +13,9 @@
white-space: nowrap; position: relative;
}
.sw-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.sw-btn--sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; }
.sw-btn--md { padding: 0.45rem 0.9rem; font-size: 0.875rem; }
.sw-btn--lg { padding: 0.6rem 1.2rem; font-size: 1rem; }
.sw-btn--sm { padding: var(--sp-1h) var(--sp-2h); font-size: 0.8rem; }
.sw-btn--md { padding: var(--sp-2) var(--sp-3); font-size: 0.875rem; }
.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: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--danger { background: var(--danger); color: var(--text-on-color); }
.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:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
@@ -60,7 +62,7 @@
/* ── 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__required { color: var(--danger); margin-left: 2px; }
.sw-field__error { font-size: 0.75rem; color: var(--danger); }
@@ -70,7 +72,7 @@
/* ── Shared 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);
font-family: var(--font); font-size: 0.875rem; outline: none;
transition: border-color var(--transition);
@@ -82,7 +84,7 @@
.sw-tooltip-wrap { position: relative; display: inline-flex; }
.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);
border-radius: var(--radius); font-size: 0.75rem; white-space: nowrap;
pointer-events: none; animation: sw-fade-in 0.15s ease;
@@ -98,7 +100,7 @@
.sw-banner {
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;
}
.sw-banner--info { background: var(--accent-dim); color: var(--accent-light); }
@@ -114,13 +116,13 @@
/* ── Toast ─────────────────────────────────── */
.sw-toast-container {
position: fixed; bottom: 1rem; right: 1rem; z-index: 10000;
display: flex; flex-direction: column-reverse; gap: 0.5rem;
position: fixed; bottom: var(--sp-4); right: var(--sp-4); z-index: 10000;
display: flex; flex-direction: column-reverse; gap: var(--sp-2);
max-width: 380px; pointer-events: none;
}
.sw-toast {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.75rem 1rem; border-radius: var(--radius-lg);
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4); border-radius: var(--radius-lg);
background: var(--bg-raised); border: 1px solid var(--border);
box-shadow: var(--shadow-lg); pointer-events: auto;
font-size: 0.875rem;
@@ -156,7 +158,7 @@
}
.sw-dialog__header {
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__close {
@@ -164,10 +166,10 @@
font-size: 1.3rem; padding: 0; line-height: 1;
}
.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 {
display: flex; gap: 0.5rem; justify-content: flex-end;
padding: 0.75rem 1.25rem; border-top: 1px solid var(--border);
display: flex; gap: var(--sp-2); justify-content: flex-end;
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; } }
@@ -178,11 +180,11 @@
z-index: 6000; min-width: 160px; max-width: 300px;
background: var(--bg-surface); border: 1px solid var(--border);
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 {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.4rem 0.75rem; cursor: pointer; font-size: 0.85rem;
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-1h) var(--sp-3); cursor: pointer; font-size: 0.85rem;
color: var(--text); transition: background var(--transition);
}
.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__label { flex: 1; }
.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) {
.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 ────────────────────────────────── */
@@ -219,7 +221,7 @@
.sw-drawer--left.sw-drawer--open { transform: translateX(0); }
.sw-drawer__header {
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__close {
@@ -227,7 +229,7 @@
font-size: 1.3rem; padding: 0; line-height: 1;
}
.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 ──────────────────────────────────── */
@@ -241,7 +243,7 @@
}
.sw-tabs__scroll::-webkit-scrollbar { display: none; }
.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);
font-family: var(--font); font-size: 0.85rem; font-weight: 500;
cursor: pointer; white-space: nowrap;
@@ -262,7 +264,7 @@
.sw-dropdown { position: relative; display: inline-block; }
.sw-dropdown__trigger {
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);
border: 1px solid var(--border); border-radius: var(--radius);
font-family: var(--font); font-size: 0.875rem; cursor: pointer;
@@ -272,19 +274,50 @@
.sw-dropdown__value { flex: 1; text-align: left; }
.sw-dropdown__chevron { color: var(--text-3); font-size: 0.7em; }
.sw-dropdown__list {
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
z-index: 6000; max-height: 240px; overflow-y: auto;
position: absolute; top: calc(100% + 4px); left: 0;
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);
border-radius: var(--radius); box-shadow: var(--shadow-lg);
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 {
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);
}
.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--disabled { opacity: 0.4; cursor: not-allowed; }
.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; }
}

View File

@@ -3,32 +3,29 @@
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 {
display: flex;
flex-direction: column;
height: 100vh;
height: 100%;
width: 100%;
overflow: hidden;
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) ── */
/* v0.6.10: banners are in-flow (template renders them), not fixed. */
.sw-shell__banner {
position: fixed;
left: 0;
right: 0;
position: static;
z-index: 900;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.45rem 1rem;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-4);
font-size: 0.8rem;
font-weight: 500;
}
@@ -60,13 +57,12 @@
/* ── Shell body — insets for fixed banners ─── */
/* v0.6.10: template banners are in-flow — no padding offset needed. */
.sw-shell__body {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
padding-top: var(--banner-top-height, 0px);
padding-bottom: var(--banner-bottom-height, 0px);
}
/* ── Announcement bar ─────────────────────── */
@@ -79,8 +75,8 @@
.sw-shell__announcement-inner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1.25rem;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-5);
font-size: 0.85rem;
}
@@ -105,7 +101,7 @@
.sw-shell__footer {
flex-shrink: 0;
padding: 0.5rem 1rem;
padding: var(--sp-2) var(--sp-4);
border-top: 1px solid var(--border);
font-size: 0.75rem;
color: var(--text-3);
@@ -129,11 +125,11 @@
display: flex;
align-items: center;
height: 44px;
padding: 0 12px;
padding: 0 var(--sp-3);
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
gap: 10px;
gap: var(--sp-3);
}
.sw-topbar__title {
@@ -151,13 +147,13 @@
.sw-topbar__slot {
display: flex;
align-items: center;
gap: 8px;
gap: var(--sp-2);
}
.sw-topbar__right {
display: flex;
align-items: center;
gap: 8px;
gap: var(--sp-2);
}
/* ── User menu trigger ───────────────────── */
@@ -170,7 +166,7 @@
cursor: pointer;
padding: 2px;
border-radius: 50%;
transition: box-shadow var(--transition, 0.15s ease);
transition: box-shadow var(--transition);
}
.sw-user-menu__trigger:hover {
box-shadow: 0 0 0 2px var(--accent-dim);
@@ -195,28 +191,28 @@
height: 32px;
background: none;
border: none;
border-radius: 6px;
color: var(--text-3, #555);
border-radius: var(--radius);
color: var(--text-3);
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.sw-notification-bell__trigger:hover {
color: var(--text, #eee);
background: var(--bg, #0e0e10);
color: var(--text);
background: var(--bg);
}
.sw-notification-bell__badge {
position: absolute;
top: 2px;
right: 2px;
background: var(--danger, #ef4444);
background: var(--danger);
color: #fff;
font-size: 9px;
font-weight: 700;
min-width: 14px;
height: 14px;
border-radius: 7px;
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
@@ -231,9 +227,9 @@
margin-top: 6px;
width: 320px;
max-height: 420px;
background: var(--bg-secondary, #151517);
border: 1px solid var(--border, #2a2a2e);
border-radius: 10px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
z-index: 200;
overflow: hidden;
@@ -245,25 +241,25 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid var(--border, #2a2a2e);
padding: var(--sp-3) var(--sp-3);
border-bottom: 1px solid var(--border);
font-size: 13px;
font-weight: 600;
color: var(--text, #eee);
color: var(--text);
}
.sw-notification-bell__mark-all {
background: none;
border: none;
color: var(--accent, #b38a4e);
color: var(--accent);
font-size: 11px;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
border-radius: var(--radius-sm);
}
.sw-notification-bell__mark-all:hover {
background: var(--accent-dim, rgba(179, 138, 78, 0.1));
background: var(--accent-dim);
}
.sw-notification-bell__list {
@@ -272,15 +268,15 @@
}
.sw-notification-bell__empty {
padding: 24px 14px;
padding: var(--sp-6) var(--sp-3);
text-align: center;
color: var(--text-3, #555);
color: var(--text-3);
font-size: 12px;
}
.sw-notification-bell__item {
padding: 10px 14px;
border-bottom: 1px solid var(--border, #2a2a2e);
padding: var(--sp-3) var(--sp-3);
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s;
}
@@ -290,11 +286,11 @@
}
.sw-notification-bell__item:hover {
background: var(--bg, #0e0e10);
background: var(--bg);
}
.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 {
@@ -303,27 +299,27 @@
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent, #b38a4e);
background: var(--accent);
margin-right: 8px;
vertical-align: middle;
}
.sw-notification-bell__item-text {
font-size: 12px;
color: var(--text, #eee);
color: var(--text);
display: inline;
}
.sw-notification-bell__item-body {
font-size: 11px;
color: var(--text-3, #555);
color: var(--text-3);
margin-top: 3px;
line-height: 1.4;
}
.sw-notification-bell__item-time {
font-size: 10px;
color: var(--text-3, #555);
color: var(--text-3);
margin-top: 3px;
display: flex;
align-items: center;
@@ -336,7 +332,7 @@
.sw-notification-bell__item-action {
font-size: 10px;
color: var(--accent, #4a9eff);
color: var(--accent);
font-weight: 600;
opacity: 0;
transition: opacity 0.15s;

View File

@@ -1,9 +1,9 @@
/* v0.25.0: Tool grants section in persona form */
.tool-grants-list {
border: 1px solid var(--border, #2a2a2e);
border-radius: 6px;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px;
background: var(--bg, #0e0e10);
background: var(--bg);
}
.tool-grants-category {
@@ -11,7 +11,7 @@
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-3, #777);
color: var(--text-3);
padding: 6px 0 2px;
margin-top: 4px;
}
@@ -27,9 +27,9 @@
gap: 6px;
padding: 3px 4px;
font-size: 12px;
color: var(--text, #eee);
color: var(--text);
cursor: pointer;
border-radius: 4px;
border-radius: var(--radius-sm);
}
.tool-grants-item:hover {

View File

@@ -19,12 +19,12 @@
.user-menu-wrap .user-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: var(--radius, 6px);
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-2);
border-radius: var(--radius);
background: none;
border: none;
color: var(--text, #eee);
color: var(--text);
cursor: pointer;
width: 100%;
font-family: inherit;
@@ -32,7 +32,7 @@
}
.user-menu-wrap .user-btn:hover {
background: var(--bg-hover, rgba(255,255,255,0.06));
background: var(--bg-hover);
}
/* ── Avatar ────────────────────────────────── */
@@ -41,13 +41,13 @@
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--accent-dim, rgba(179, 138, 78, 0.2));
background: var(--accent-dim);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: var(--accent, #b38a4e);
color: var(--accent);
flex-shrink: 0;
position: relative;
overflow: hidden;
@@ -78,7 +78,7 @@
/* Sidebar collapsed: hide label, center avatar */
.sidebar.collapsed .user-menu-wrap .user-btn {
justify-content: center;
padding: 6px 0;
padding: var(--sp-2) 0;
gap: 0;
}
@@ -101,7 +101,7 @@
@media (max-width: 768px) {
/* User menu button: ensure 44px touch target */
.user-menu-wrap .user-btn {
padding: 8px;
padding: var(--sp-2);
min-height: 44px;
}

View File

@@ -2,7 +2,39 @@
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
@@ -15,6 +47,7 @@
--bg-surface: #18181b;
--bg-raised: #222227;
--bg-hover: #2a2a30;
--bg-active: #2f2f38;
--bg-elevated: #42424e;
--border: #2e2e35;
--border-elevated: #555560;
@@ -22,11 +55,11 @@
--text: #e8e8ed;
--text-2: #9898a8;
--text-3: #6b6b7b;
--accent: #6c9fff;
--accent-hover: #84b0ff;
--accent-dim: rgba(108,159,255,0.12);
--accent: #6493ed;
--accent-hover: #7ca8f5;
--accent-dim: rgba(100,147,237,0.12);
--danger: #ef4444;
--success: #22c55e;
--success: #1dab51;
--warning: #eab308;
--purple: #a78bfa;
--purple-dim: rgba(167,139,250,0.10);
@@ -52,12 +85,13 @@
--overlay: rgba(0,0,0,0.55);
--glass: rgba(255,255,255,0.03);
--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);
--bg-code: #1a1a22;
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
/* ── Layout ──────────────────────────────── */
--radius-sm: 4px;
--radius: 8px;
--radius-lg: 12px;
--sidebar-w: 260px;
@@ -66,6 +100,25 @@
--mono: 'JetBrains Mono', 'Söhne Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace;
--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-top-height: 0px;
--banner-bottom-height: 0px;
@@ -81,13 +134,14 @@
--bg-surface: #ffffff;
--bg-raised: #eff0f3;
--bg-hover: #e5e6eb;
--bg-active: #dddee5;
--bg-elevated: #ffffff;
--border: #d5d6dc;
--border-elevated: #c0c0c8;
--border-light: #c2c3cb;
--text: #1a1a2e;
--text-2: #555770;
--text-3: #8b8da3;
--text-3: #787a92;
--accent: #4a7cdb;
--accent-hover: #3968c4;
--accent-dim: rgba(74,124,219,0.09);
@@ -102,8 +156,8 @@
--accent-light: #2563eb;
--danger-light: #dc2626;
--success-light: #16a34a;
--warning-light: #ca8a04;
--success-light: #128a3e;
--warning-light: #a97200;
--danger-dim: rgba(220,38,38,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; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-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); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }

View File

@@ -10,7 +10,7 @@
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: var(--radius);
margin-bottom: 4px;
background: var(--bg-surface);
cursor: grab;
@@ -27,15 +27,15 @@
}
.wf-stage-name { flex: 1; font-size: 13px; font-weight: 500; }
.wf-stage-row .badge { font-size: 10px; }
.wf-stage-row .btn-small { opacity: 0; transition: opacity 0.15s; }
.wf-stage-row:hover .btn-small { opacity: 1; }
.wf-stage-row .sw-btn--sm { opacity: 0; transition: opacity 0.15s; }
.wf-stage-row:hover .sw-btn--sm { opacity: 1; }
/* ── Queue sidebar ───────────────────── */
.sb-queue-badge {
background: var(--accent);
color: #fff;
border-radius: 10px;
border-radius: var(--radius-lg);
padding: 1px 7px;
font-size: 11px;
font-weight: 600;
@@ -47,7 +47,7 @@
gap: 8px;
padding: 6px 12px;
cursor: pointer;
border-radius: 6px;
border-radius: var(--radius);
font-size: 13px;
transition: background 0.15s;
}
@@ -55,16 +55,16 @@
.sb-queue-item.active { background: var(--accent-dim); color: var(--accent); }
.sb-queue-icon { font-size: 14px; }
.sb-queue-label { flex: 1; }
.sb-queue-item .btn-small {
.sb-queue-item .sw-btn--sm {
opacity: 0; padding: 2px 6px; font-size: 11px;
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 */
.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-item .btn-small { display: none; }
.sidebar.collapsed .sb-queue-item .sw-btn--sm { display: none; }
.sidebar.collapsed .sb-queue-badge { display: none !important; }
/* Unpin button */
@@ -79,7 +79,7 @@
/* Browse workflows button */
.sb-queue-browse {
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;
}
.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-row {
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;
}
.wf-browse-row:hover { background: var(--bg-raised); }
@@ -109,7 +109,7 @@
/* ── 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-warn { background: rgba(210, 153, 34, 0.15); color: #d29922; }
@@ -143,14 +143,14 @@
.wf-bar-status {
font-size: 11px;
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.completed { background: var(--success-dim); color: var(--success-light); }
.wf-bar-status.stale { background: var(--warning-dim); color: var(--warning-light); }
.wf-bar-spacer { flex: 1; }
.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 */
.wf-stage-row.dragging { opacity: 0.4; }
@@ -164,7 +164,7 @@
gap: 6px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: var(--radius);
margin-bottom: 4px;
background: var(--bg-surface);
flex-wrap: wrap;
@@ -177,7 +177,7 @@
font-size: 12px;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: 4px;
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -102,13 +102,13 @@ function DebugModal({ engine }) {
</div>
<div class="modal-footer debug-modal-footer">
<div class="debug-footer-left">
<button class="btn-danger btn-small" 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=${() => { setActiveTab('console'); engine.runDiagnostics(); }}>Diagnostics</button>
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${onPurge}>Purge Cache</button>
</div>
<div class="debug-footer-right">
<button class="btn-secondary btn-small" onClick=${onClear}>Clear</button>
<button class="btn-secondary btn-small" 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=${onClear}>Clear</button>
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => engine.copyLog()}>Copy</button>
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => engine.downloadLog()}>Export</button>
</div>
</div>
</div>

View File

@@ -16,21 +16,28 @@ export function Menu({ items = [], anchor, open, direction = 'down-right', onSel
const selectableItems = items.filter(i => !i.divider && !i.disabled);
// Position relative to anchor
// When the surface uses CSS transform: scale(), getBoundingClientRect()
// returns scaled coordinates but position:fixed uses viewport coordinates.
// Dividing by the scale factor converts back to true viewport pixels.
// Position relative to anchor, accounting for CSS zoom on #surfaceInner.
// getBoundingClientRect() returns viewport coords, but position:fixed inside
// a zoomed ancestor needs coords divided by the zoom factor.
useEffect(() => {
if (!open || !anchor) return;
const scale = window.sw?.shell?.getScale?.() || 1;
const raw = typeof anchor.getBoundingClientRect === 'function'
const rawRect = typeof anchor.getBoundingClientRect === 'function'
? anchor.getBoundingClientRect()
: anchor;
const rect = scale !== 1
? { top: raw.top / scale, bottom: raw.bottom / scale, left: raw.left / scale, right: raw.right / scale }
: raw;
const vw = window.innerWidth / scale;
const vh = window.innerHeight / scale;
// Detect CSS zoom on surface container
const zoomEl = document.getElementById('surfaceInner');
const zoom = zoomEl ? (parseFloat(zoomEl.style.zoom) || 1) : 1;
// 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;
const [vDir, hDir] = direction.split('-');

View File

@@ -287,7 +287,8 @@ export function createDomains(restClient) {
// ── 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: {

View File

@@ -36,15 +36,18 @@ export function createAuth() {
// ── Token persistence ───────────────────────
let _cookieMaxAge = 604800; // default 7 days, updated from server response
function _saveTokens() {
localStorage.setItem(_storageKey, JSON.stringify({
accessToken: _accessToken,
refreshToken: _refreshToken,
user: _user,
cookieMaxAge: _cookieMaxAge,
}));
// Cookie sync for Go template page auth
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 {
document.cookie = 'arm_token=; path=/; max-age=0';
}
@@ -57,6 +60,7 @@ export function createAuth() {
_accessToken = saved.accessToken || null;
_refreshToken = saved.refreshToken || null;
_user = saved.user || null;
if (saved.cookieMaxAge) _cookieMaxAge = saved.cookieMaxAge;
}
} catch (_) { /* corrupt storage */ }
}
@@ -81,6 +85,7 @@ export function createAuth() {
_accessToken = data.access_token;
_refreshToken = data.refresh_token;
_user = data.user;
if (data.refresh_expires_in) _cookieMaxAge = data.refresh_expires_in;
_saveTokens();
_scheduleRefresh(data.expires_in || 900);
}
@@ -149,8 +154,8 @@ export function createAuth() {
/**
* Login with credentials. Stores tokens, fetches permissions, emits event.
*/
async login(login, password) {
const data = await _restClient.post('/api/v1/auth/login', { login, password });
async login(login, password, keepLogin = false) {
const data = await _restClient.post('/api/v1/auth/login', { login, password, keep_login: keepLogin });
_setAuth(data);
await _fetchPermissions();
_emit('auth.login', { user: auth.user }, { localOnly: true });
@@ -245,9 +250,30 @@ export function createAuth() {
if (_accessToken) {
_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) ──
_getToken() { return _accessToken; },

View File

@@ -24,6 +24,7 @@ import { createActions } from './actions.js';
import { createRealtime } from './realtime.js';
import { createRenderers } from './renderers.js';
import { createMarkdown } from './markdown.js';
import { createUsers } from './users.js';
import { confirm } from '../primitives/confirm.js';
import { prompt } from '../primitives/prompt.js';
@@ -117,21 +118,17 @@ export async function boot() {
// Markdown — unified renderer (marked + DOMPurify + sw.renderers pipeline)
sw.markdown = markdown;
// Users — identity resolution with local caching
sw.users = createUsers(restClient);
// Shell helpers — imperative confirm/prompt backed by primitives
sw.confirm = confirm;
sw.prompt = prompt;
// Shell — layout utilities (decouples surface code from shell DOM)
const _shell = {
/** CSS transform scale on #surfaceInner (appearance zoom). */
getScale() {
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;
},
/** @deprecated v0.6.10 — CSS zoom handles reflow; no scale correction needed. */
getScale() { return 1; },
/** Topbar — set after dynamic import below */
Topbar: null,
};
@@ -196,7 +193,24 @@ export async function boot() {
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 });
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
console.log(`[sw] SDK v${sw._sdk} ready`);

View File

@@ -103,7 +103,7 @@ export function createMarkdown(renderers) {
renderer(token) {
const target = _escapeAttr(token.target);
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
View 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 });
}

View File

@@ -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
*
* ┌─────────────────────────────────┐ ← fixed banner (top, optional)
@@ -22,20 +24,10 @@
const { html } = window;
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' }) {
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`
<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>
</div>
`;

View File

@@ -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
*
* ┌───────────────────────────────┐

View File

@@ -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
*
* Deliberately thin. Error boundaries and

View File

@@ -27,7 +27,7 @@ function _currentSurface() {
export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
const [open, setOpen] = useState(false);
const [extSurfaces, setExtSurfaces] = useState([]);
const [allSurfaces, setAllSurfaces] = useState([]);
const btnRef = useRef(null);
const sw = window.sw;
@@ -35,17 +35,19 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
const authenticated = sw?.auth?.isAuthenticated;
// 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']);
const CORE_IDS = new Set(['admin', 'settings', 'team-admin', 'workflow', 'workflow-landing', 'docs', 'welcome']);
useEffect(() => {
if (!sw?.api?.surfaces?.list) return;
sw.api.surfaces.list().then(data => {
const raw = Array.isArray(data) ? data : data?.data || [];
setExtSurfaces(raw.filter(s => !CORE_IDS.has(s.id)));
setAllSurfaces(raw);
}).catch(() => {});
}, [authenticated]);
const extSurfaces = allSurfaces.filter(s => !CORE_IDS.has(s.id));
const enabledIds = new Set(allSurfaces.map(s => s.id));
const items = useMemo(() => {
const current = _currentSurface();
const list = [];
@@ -68,13 +70,13 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
}
// ── Standard items ─────────────────────
// Docs — skip if current surface IS docs
if (current !== 'docs') {
// Docs — only if enabled and not current
if (current !== 'docs' && enabledIds.has('docs')) {
list.push({ label: 'Docs', action: 'docs', icon: '\ud83d\udcd6' });
}
// Settings — skip if current surface IS settings
if (current !== 'settings') {
// Settings — only if enabled and not current
if (current !== 'settings' && enabledIds.has('settings')) {
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
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' });
}
@@ -98,7 +100,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
list.push({ label: 'Sign Out', action: 'sign-out' });
return list;
}, [user, authenticated, extraItems, extSurfaces]);
}, [user, authenticated, extraItems, extSurfaces, enabledIds]);
function handleSelect(action) {
setOpen(false);

View File

@@ -77,9 +77,9 @@ export default function AuditSection() {
</div>
<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>
<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>
`
}

View File

@@ -118,13 +118,13 @@ export default function BackupSection() {
return html`
<div>
<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'}
</button>
<button class="btn" onclick=${() => createBackup(true)} disabled=${creating || restoring}>
${creating ? 'Creating\u2026' : 'Save to Server'}
</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'}
</button>
</div>
@@ -149,8 +149,8 @@ export default function BackupSection() {
<td>${formatBytes(b.size)}</td>
<td>${formatDate(b.created_at)}</td>
<td>
<button class="btn 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--secondary sw-btn--sm" onclick=${() => downloadBackup(b.name)}>Download</button>
<button class="sw-btn sw-btn--danger sw-btn--sm" onclick=${() => deleteBackup(b.name)} style="margin-left:4px;">Delete</button>
</td>
</tr>
`)}

Some files were not shown because too many files have changed in this diff Show More