8 Commits

Author SHA1 Message Date
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
113 changed files with 5248 additions and 2293 deletions

View File

@@ -2,6 +2,329 @@
All notable changes to Armature are documented here. All notable changes to Armature are documented here.
## 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 ## v0.6.7 — Native mTLS
End-to-end mutual TLS without a reverse proxy. Targets systemd+podman End-to-end mutual TLS without a reverse proxy. Targets systemd+podman

151
ROADMAP-UI.md Normal file
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 # Armature — Roadmap
## Current: v0.6.7Native mTLS ## Current: v0.6.14Visual Polish
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox, Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
storage, realtime, and ops are kernel primitives. Everything else is an extension. storage, realtime, and ops are kernel primitives. Everything else is an extension.

View File

@@ -1 +1 @@
0.6.7 0.6.15

View File

@@ -3,14 +3,14 @@
## Docker Single-Instance ## Docker Single-Instance
```bash ```bash
docker pull ghcr.io/armature/armature:latest docker pull gobha/armature:latest
docker run -p 8080:80 \ docker run -p 8080:80 \
-e ARMATURE_ADMIN_USERNAME=admin \ -e ARMATURE_ADMIN_USERNAME=admin \
-e ARMATURE_ADMIN_PASSWORD=changeme \ -e ARMATURE_ADMIN_PASSWORD=changeme \
-e JWT_SECRET="$(openssl rand -hex 32)" \ -e JWT_SECRET="$(openssl rand -hex 32)" \
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \ -e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
-v armature-data:/data \ -v armature-data:/data \
ghcr.io/armature/armature:latest gobha/armature:latest
``` ```
This runs with SQLite and PVC storage. Suitable for evaluation and small teams. This runs with SQLite and PVC storage. Suitable for evaluation and small teams.
@@ -29,7 +29,7 @@ services:
- pg_data:/var/lib/postgresql/data - pg_data:/var/lib/postgresql/data
armature: armature:
image: ghcr.io/armature/armature:latest image: gobha/armature:latest
ports: ports:
- "8080:80" - "8080:80"
environment: environment:

View File

@@ -3,11 +3,11 @@
## Quick Start ## Quick Start
```bash ```bash
docker pull ghcr.io/armature/armature:latest docker pull gobha/armature:latest
docker run -p 8080:80 \ docker run -p 8080:80 \
-e ARMATURE_ADMIN_USERNAME=admin \ -e ARMATURE_ADMIN_USERNAME=admin \
-e ARMATURE_ADMIN_PASSWORD=changeme \ -e ARMATURE_ADMIN_PASSWORD=changeme \
ghcr.io/armature/armature:latest gobha/armature:latest
``` ```
On first run, bundled packages are automatically installed — workflows, surfaces, and extensions are ready to use immediately. On first run, bundled packages are automatically installed — workflows, surfaces, and extensions are ready to use immediately.
@@ -66,12 +66,12 @@ Set `BUNDLED_PACKAGES` to control which packages are installed:
# Install ALL packages (everything in the image) # Install ALL packages (everything in the image)
docker run -p 8080:80 \ docker run -p 8080:80 \
-e BUNDLED_PACKAGES="*" \ -e BUNDLED_PACKAGES="*" \
ghcr.io/armature/armature:latest gobha/armature:latest
# Install specific packages only # Install specific packages only
docker run -p 8080:80 \ docker run -p 8080:80 \
-e BUNDLED_PACKAGES="notes,tasks,schedules" \ -e BUNDLED_PACKAGES="notes,tasks,schedules" \
ghcr.io/armature/armature:latest gobha/armature:latest
``` ```
Empty (default) installs the curated default set. Use `*` to install all packages. This is useful for Helm charts where different environments need different packages. Empty (default) installs the curated default set. Use `*` to install all packages. This is useful for Helm charts where different environments need different packages.
@@ -83,7 +83,7 @@ Set `SKIP_BUNDLED_PACKAGES=true` to prevent bundled packages from being installe
```bash ```bash
docker run -p 8080:80 \ docker run -p 8080:80 \
-e SKIP_BUNDLED_PACKAGES=true \ -e SKIP_BUNDLED_PACKAGES=true \
ghcr.io/armature/armature:latest gobha/armature:latest
``` ```
### Custom Bundle Directory ### Custom Bundle Directory
@@ -94,7 +94,7 @@ Override the default bundled packages location with `BUNDLED_PACKAGES_DIR`:
docker run -p 8080:80 \ docker run -p 8080:80 \
-e BUNDLED_PACKAGES_DIR=/custom/packages \ -e BUNDLED_PACKAGES_DIR=/custom/packages \
-v /host/packages:/custom/packages \ -v /host/packages:/custom/packages \
ghcr.io/armature/armature:latest gobha/armature:latest
``` ```
## Builder Image ## Builder Image
@@ -102,7 +102,7 @@ docker run -p 8080:80 \
The builder image pre-caches Go modules and Node dependencies for faster custom builds. The builder image pre-caches Go modules and Node dependencies for faster custom builds.
```bash ```bash
docker pull ghcr.io/armature/builder:latest docker pull gobha/armature-builder:latest
``` ```
### What It Caches ### What It Caches
@@ -117,7 +117,7 @@ docker pull ghcr.io/armature/builder:latest
Reference the builder image as a base stage in your Dockerfile: Reference the builder image as a base stage in your Dockerfile:
```dockerfile ```dockerfile
FROM ghcr.io/armature/builder:latest AS builder FROM gobha/armature-builder:latest AS builder
WORKDIR /app WORKDIR /app
COPY server/ . COPY server/ .
RUN go build -ldflags="-s -w" -o /bin/armature . RUN go build -ldflags="-s -w" -o /bin/armature .
@@ -148,7 +148,7 @@ To exclude specific packages from the bundle, either:
### Forking for Custom Builds ### Forking for Custom Builds
```bash ```bash
git clone https://github.com/armature/armature.git git clone https://github.com/gobha/armature.git
cd armature cd armature
# Add/modify packages # Add/modify packages
@@ -189,13 +189,13 @@ docker run -p 8080:80 \
-e DATABASE_URL="postgres://user:pass@host:5432/armature?sslmode=require" \ -e DATABASE_URL="postgres://user:pass@host:5432/armature?sslmode=require" \
-e JWT_SECRET="$(openssl rand -hex 32)" \ -e JWT_SECRET="$(openssl rand -hex 32)" \
-e ENCRYPTION_KEY="$(openssl rand -hex 32)" \ -e ENCRYPTION_KEY="$(openssl rand -hex 32)" \
ghcr.io/armature/armature:latest gobha/armature:latest
# SQLite (evaluation only) # SQLite (evaluation only)
docker run -p 8080:80 \ docker run -p 8080:80 \
-e DB_DRIVER=sqlite \ -e DB_DRIVER=sqlite \
-v armature-data:/data \ -v armature-data:/data \
ghcr.io/armature/armature:latest gobha/armature:latest
``` ```
### Storage ### Storage

196
docs/EXTENSION-CSS.md Normal file
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"] cid = conv["id"]
# Add creator as admin participant # Add creator as admin participant
# NOTE: display_name is a snapshot captured at creation time.
# DEPRECATED v0.6.15 — UI resolves display names from users table via sw.users.resolve().
# Column retained for backward compatibility; UI no longer relies on this value for display.
if creator_id: if creator_id:
db.insert("participants", { db.insert("participants", {
"conversation_id": cid, "conversation_id": cid,
@@ -110,7 +113,7 @@ def create(title, type="group", participants=None, creator_id="", creator_displa
"conversation_id": cid, "conversation_id": cid,
"participant_id": pid, "participant_id": pid,
"participant_type": _str(p.get("type", "user")), "participant_type": _str(p.get("type", "user")),
"display_name": _str(p.get("display_name", "")), "display_name": _str(p.get("display_name", "")), # DEPRECATED v0.6.15 — snapshot only
"role": _str(p.get("role", "member")), "role": _str(p.get("role", "member")),
"joined_at": "", "joined_at": "",
}) })
@@ -213,7 +216,7 @@ def add_participant(conversation_id, participant_id, participant_type="user", di
"conversation_id": cid, "conversation_id": cid,
"participant_id": pid, "participant_id": pid,
"participant_type": _str(participant_type), "participant_type": _str(participant_type),
"display_name": _str(display_name), "display_name": _str(display_name), # DEPRECATED v0.6.15 — snapshot only
"role": _str(role), "role": _str(role),
"joined_at": "", "joined_at": "",
}) })

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,7 @@
if (!mount) return; if (!mount) return;
const surface = document.createElement('div'); const surface = document.createElement('div');
surface.className = 'surface-dashboard'; surface.className = 'ext-dashboard';
surface.id = 'dashboardSurface'; surface.id = 'dashboardSurface';
mount.appendChild(surface); mount.appendChild(surface);
@@ -41,12 +41,12 @@
// ── Body ── // ── Body ──
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'dashboard-body'; body.className = 'ext-dashboard-body';
surface.appendChild(body); surface.appendChild(body);
// ── Sidebar (tabs: activity + notes) ── // ── Sidebar (tabs: activity + notes) ──
const sidebar = document.createElement('div'); const sidebar = document.createElement('div');
sidebar.className = 'dashboard-sidebar'; sidebar.className = 'ext-dashboard-sidebar';
body.appendChild(sidebar); body.appendChild(sidebar);
// sw.tabs — two tabs // sw.tabs — two tabs
@@ -83,14 +83,14 @@
// ── Main content area ── // ── Main content area ──
const main = document.createElement('div'); const main = document.createElement('div');
main.className = 'dashboard-main'; main.className = 'ext-dashboard-main';
body.appendChild(main); body.appendChild(main);
_buildMainContent(main); _buildMainContent(main);
// ── Theme reactivity ── // ── Theme reactivity ──
sw.theme.on('change', function (resolved) { sw.theme.on('change', function (resolved) {
const cards = main.querySelectorAll('.dashboard-card'); const cards = main.querySelectorAll('.ext-dashboard-card');
cards.forEach(function (c) { cards.forEach(function (c) {
c.style.borderColor = ''; // reset to CSS default for new theme c.style.borderColor = ''; // reset to CSS default for new theme
}); });
@@ -98,7 +98,7 @@
// ── Cross-component events ── // ── Cross-component events ──
sw.on('dashboard.filter.changed', function (payload) { sw.on('dashboard.filter.changed', function (payload) {
_loadChannels(main.querySelector('.dashboard-cards'), payload.value); _loadChannels(main.querySelector('.ext-dashboard-cards'), payload.value);
}); });
console.log('[DashboardPkg] Mounted'); console.log('[DashboardPkg] Mounted');
@@ -108,15 +108,15 @@
function _buildTopbar() { function _buildTopbar() {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'dashboard-topbar'; el.className = 'ext-dashboard-topbar';
el.innerHTML = el.innerHTML =
'<a href="' + esc(base) + '/" class="dashboard-topbar-back" title="Back to chat">' + '<a href="' + esc(base) + '/" class="ext-dashboard-topbar-back" title="Back to chat">' +
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>' + '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>' +
'Back' + 'Back' +
'</a>' + '</a>' +
'<div class="dashboard-topbar-sep"></div>' + '<div class="ext-dashboard-topbar-sep"></div>' +
'<span class="dashboard-topbar-title">Dashboard</span>' + '<span class="ext-dashboard-topbar-title">Dashboard</span>' +
'<div class="dashboard-topbar-spacer"></div>'; '<div class="ext-dashboard-topbar-spacer"></div>';
// sw.toolbar — action buttons // sw.toolbar — action buttons
const toolbarItems = [ const toolbarItems = [
@@ -125,7 +125,7 @@
icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>', icon: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>',
title: 'Refresh', title: 'Refresh',
onClick: function () { onClick: function () {
const cards = document.querySelector('.dashboard-cards'); const cards = document.querySelector('.ext-dashboard-cards');
if (cards) _loadChannels(cards, _currentFilter); if (cards) _loadChannels(cards, _currentFilter);
sw.toast('Refreshed', 'success'); sw.toast('Refreshed', 'success');
}, },
@@ -155,22 +155,22 @@
// sw.user — greeting // sw.user — greeting
const user = sw.user; const user = sw.user;
const greeting = document.createElement('div'); const greeting = document.createElement('div');
greeting.className = 'dashboard-greeting'; greeting.className = 'ext-dashboard-greeting';
greeting.textContent = 'Welcome back' + (user ? ', ' + (user.display_name || user.username) : ''); greeting.textContent = 'Welcome back' + (user ? ', ' + (user.display_name || user.username || 'Unknown') : '');
main.appendChild(greeting); main.appendChild(greeting);
const subtitle = document.createElement('div'); const subtitle = document.createElement('div');
subtitle.className = 'dashboard-subtitle'; subtitle.className = 'ext-dashboard-subtitle';
subtitle.textContent = 'Your recent conversations' + (sw.isAdmin ? ' \u00b7 Admin' : ''); subtitle.textContent = 'Your recent conversations' + (sw.isAdmin ? ' \u00b7 Admin' : '');
main.appendChild(subtitle); main.appendChild(subtitle);
// ── Filter bar ── // ── Filter bar ──
const filterBar = document.createElement('div'); const filterBar = document.createElement('div');
filterBar.className = 'dashboard-filter-bar'; filterBar.className = 'ext-dashboard-filter-bar';
main.appendChild(filterBar); main.appendChild(filterBar);
const filterLabel = document.createElement('span'); const filterLabel = document.createElement('span');
filterLabel.className = 'dashboard-filter-label'; filterLabel.className = 'ext-dashboard-filter-label';
filterLabel.textContent = 'Filter'; filterLabel.textContent = 'Filter';
filterBar.appendChild(filterLabel); filterBar.appendChild(filterLabel);
@@ -192,7 +192,7 @@
// ── Cards ── // ── Cards ──
const cards = document.createElement('div'); const cards = document.createElement('div');
cards.className = 'dashboard-cards'; cards.className = 'ext-dashboard-cards';
main.appendChild(cards); main.appendChild(cards);
_loadChannels(cards, ''); _loadChannels(cards, '');
@@ -200,16 +200,16 @@
// ── Admin section (sw.isAdmin) ── // ── Admin section (sw.isAdmin) ──
if (sw.isAdmin) { if (sw.isAdmin) {
const adminSection = document.createElement('div'); const adminSection = document.createElement('div');
adminSection.className = 'dashboard-admin-section'; adminSection.className = 'ext-dashboard-admin-section';
main.appendChild(adminSection); main.appendChild(adminSection);
const sectionTitle = document.createElement('div'); const sectionTitle = document.createElement('div');
sectionTitle.className = 'dashboard-section-title'; sectionTitle.className = 'ext-dashboard-section-title';
sectionTitle.textContent = 'Administration'; sectionTitle.textContent = 'Administration';
adminSection.appendChild(sectionTitle); adminSection.appendChild(sectionTitle);
const adminCards = document.createElement('div'); const adminCards = document.createElement('div');
adminCards.className = 'dashboard-cards'; adminCards.className = 'ext-dashboard-cards';
adminSection.appendChild(adminCards); adminSection.appendChild(adminCards);
_loadAdminCards(adminCards); _loadAdminCards(adminCards);
@@ -220,7 +220,7 @@
async function _loadChannels(container, typeFilter) { async function _loadChannels(container, typeFilter) {
if (!container) return; if (!container) return;
container.innerHTML = '<div class="dashboard-empty">Loading\u2026</div>'; container.innerHTML = '<div class="ext-dashboard-empty">Loading\u2026</div>';
try { try {
// sw.api — real API call // sw.api — real API call
@@ -231,7 +231,7 @@
container.innerHTML = ''; container.innerHTML = '';
if (!channels.length) { if (!channels.length) {
container.innerHTML = '<div class="dashboard-empty">No channels found</div>'; container.innerHTML = '<div class="ext-dashboard-empty">No channels found</div>';
return; return;
} }
@@ -239,31 +239,31 @@
container.appendChild(_buildChannelCard(ch)); container.appendChild(_buildChannelCard(ch));
}); });
} catch (e) { } catch (e) {
container.innerHTML = '<div class="dashboard-empty">Failed to load: ' + (typeof esc === 'function' ? esc(e.message) : e.message) + '</div>'; container.innerHTML = '<div class="ext-dashboard-empty">Failed to load: ' + (typeof esc === 'function' ? esc(e.message) : e.message) + '</div>';
} }
} }
function _buildChannelCard(ch) { function _buildChannelCard(ch) {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'dashboard-card'; card.className = 'ext-dashboard-card';
const header = document.createElement('div'); const header = document.createElement('div');
header.className = 'dashboard-card-header'; header.className = 'ext-dashboard-card-header';
card.appendChild(header); card.appendChild(header);
const title = document.createElement('div'); const title = document.createElement('div');
title.className = 'dashboard-card-title'; title.className = 'ext-dashboard-card-title';
title.textContent = ch.title || ch.name || 'Untitled'; title.textContent = ch.title || ch.name || 'Untitled';
header.appendChild(title); header.appendChild(title);
const meta = document.createElement('div'); const meta = document.createElement('div');
meta.className = 'dashboard-card-meta'; meta.className = 'ext-dashboard-card-meta';
meta.textContent = ch.type || ''; meta.textContent = ch.type || '';
card.appendChild(meta); card.appendChild(meta);
if (ch.description) { if (ch.description) {
const desc = document.createElement('div'); const desc = document.createElement('div');
desc.className = 'dashboard-card-desc'; desc.className = 'ext-dashboard-card-desc';
desc.textContent = ch.description.slice(0, 120); desc.textContent = ch.description.slice(0, 120);
card.appendChild(desc); card.appendChild(desc);
} }
@@ -271,14 +271,14 @@
const updated = ch.updated_at || ch.created_at; const updated = ch.updated_at || ch.created_at;
if (updated) { if (updated) {
const date = document.createElement('div'); const date = document.createElement('div');
date.className = 'dashboard-card-meta'; date.className = 'ext-dashboard-card-meta';
date.textContent = new Date(updated).toLocaleDateString(); date.textContent = new Date(updated).toLocaleDateString();
card.appendChild(date); card.appendChild(date);
} }
// ── Card action menu ── // ── Card action menu ──
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'dashboard-card-actions'; actions.className = 'ext-dashboard-card-actions';
card.appendChild(actions); card.appendChild(actions);
const menuBtn = document.createElement('button'); const menuBtn = document.createElement('button');
@@ -343,8 +343,8 @@
(ch.description ? '<div style="font-size:12px;color:var(--text-2);margin-bottom:8px;"><strong>Description:</strong> ' + (typeof esc === 'function' ? esc(ch.description) : ch.description) + '</div>' : '') + (ch.description ? '<div style="font-size:12px;color:var(--text-2);margin-bottom:8px;"><strong>Description:</strong> ' + (typeof esc === 'function' ? esc(ch.description) : ch.description) + '</div>' : '') +
(ch.created_at ? '<div style="font-size:12px;color:var(--text-3);margin-bottom:16px;">Created: ' + new Date(ch.created_at).toLocaleString() + '</div>' : '') + (ch.created_at ? '<div style="font-size:12px;color:var(--text-3);margin-bottom:16px;">Created: ' + new Date(ch.created_at).toLocaleString() + '</div>' : '') +
'<div style="display:flex;justify-content:flex-end;gap:8px;">' + '<div style="display:flex;justify-content:flex-end;gap:8px;">' +
'<button class="btn-small" id="dashDetailClose">Close</button>' + '<button class="sw-btn sw-btn--secondary sw-btn--sm" id="dashDetailClose">Close</button>' +
'<button class="btn-small btn-primary" id="dashDetailOpen">Open Chat</button>' + '<button class="sw-btn sw-btn--primary sw-btn--sm" id="dashDetailOpen">Open Chat</button>' +
'</div>'; '</div>';
modal.appendChild(box); modal.appendChild(box);
@@ -372,7 +372,7 @@
// sw.toast — success feedback // sw.toast — success feedback
sw.toast('Channel deleted', 'success'); sw.toast('Channel deleted', 'success');
// Refresh cards // Refresh cards
const cards = document.querySelector('.dashboard-cards'); const cards = document.querySelector('.ext-dashboard-cards');
if (cards) _loadChannels(cards, _currentFilter); if (cards) _loadChannels(cards, _currentFilter);
} catch (e) { } catch (e) {
// sw.toast — error feedback // sw.toast — error feedback
@@ -390,21 +390,21 @@
container.innerHTML = ''; container.innerHTML = '';
packages.forEach(function (pkg) { packages.forEach(function (pkg) {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'dashboard-card'; card.className = 'ext-dashboard-card';
card.innerHTML = card.innerHTML =
'<div class="dashboard-card-header">' + '<div class="ext-dashboard-card-header">' +
'<div class="dashboard-card-title">' + (typeof esc === 'function' ? esc(pkg.title || pkg.id) : (pkg.title || pkg.id)) + '</div>' + '<div class="ext-dashboard-card-title">' + (typeof esc === 'function' ? esc(pkg.title || pkg.id) : (pkg.title || pkg.id)) + '</div>' +
'</div>' + '</div>' +
'<div class="dashboard-card-meta">' + (typeof esc === 'function' ? esc(pkg.type || '') : (pkg.type || '')) + ' \u00b7 ' + (typeof esc === 'function' ? esc(pkg.tier || '') : (pkg.tier || '')) + '</div>' + '<div class="ext-dashboard-card-meta">' + (typeof esc === 'function' ? esc(pkg.type || '') : (pkg.type || '')) + ' \u00b7 ' + (typeof esc === 'function' ? esc(pkg.tier || '') : (pkg.tier || '')) + '</div>' +
'<div class="dashboard-card-desc">' + (typeof esc === 'function' ? esc(pkg.description || '') : (pkg.description || '')) + '</div>'; '<div class="ext-dashboard-card-desc">' + (typeof esc === 'function' ? esc(pkg.description || '') : (pkg.description || '')) + '</div>';
container.appendChild(card); container.appendChild(card);
}); });
if (!packages.length) { if (!packages.length) {
container.innerHTML = '<div class="dashboard-empty">No packages installed</div>'; container.innerHTML = '<div class="ext-dashboard-empty">No packages installed</div>';
} }
} catch (_) { } catch (_) {
container.innerHTML = '<div class="dashboard-empty">Failed to load packages</div>'; container.innerHTML = '<div class="ext-dashboard-empty">Failed to load packages</div>';
} }
} }

View File

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

View File

@@ -56,7 +56,7 @@
// Wrap in surface container for CSS scoping // Wrap in surface container for CSS scoping
const surface = document.createElement('div'); const surface = document.createElement('div');
surface.className = 'surface-editor'; surface.className = 'ext-editor';
surface.id = 'editorSurface'; surface.id = 'editorSurface';
mount.appendChild(surface); mount.appendChild(surface);
@@ -84,7 +84,7 @@
// Build body + bootstrap // Build body + bootstrap
const body = document.createElement('div'); const body = document.createElement('div');
body.className = 'editor-body'; body.className = 'ext-editor-body';
body.id = 'editorBody'; body.id = 'editorBody';
surface.appendChild(body); surface.appendChild(body);
@@ -113,32 +113,32 @@
function _buildTopbar(wsName) { function _buildTopbar(wsName) {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'editor-topbar'; el.className = 'ext-editor-topbar';
el.id = 'editorTopbar'; el.id = 'editorTopbar';
el.innerHTML = el.innerHTML =
'<a href="' + esc(base) + '/" class="editor-topbar-back" title="Back to chat">' + '<a href="' + esc(base) + '/" class="ext-editor-topbar-back" title="Back to chat">' +
'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>' + '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>' +
'Back' + 'Back' +
'</a>' + '</a>' +
'<div class="editor-topbar-sep"></div>' + '<div class="ext-editor-topbar-sep"></div>' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>' + '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent);"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>' +
'<div class="editor-ws-selector" id="editorWsSelector">' + '<div class="ext-editor-ws-selector" id="editorWsSelector">' +
'<button class="editor-ws-selector-btn" id="editorWsSelectorBtn">' + '<button class="ext-editor-ws-selector-btn" id="editorWsSelectorBtn">' +
'<span id="editorWorkspaceName">' + esc(wsName || 'Editor') + '</span>' + '<span id="editorWorkspaceName">' + esc(wsName || 'Editor') + '</span>' +
'<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>' + '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>' +
'</button>' + '</button>' +
'<div class="editor-ws-dropdown" id="editorWsDropdown">' + '<div class="ext-editor-ws-dropdown" id="editorWsDropdown">' +
'<div id="editorWsList" class="editor-ws-list"></div>' + '<div id="editorWsList" class="ext-editor-ws-list"></div>' +
'<div class="editor-ws-dropdown-divider"></div>' + '<div class="ext-editor-ws-dropdown-divider"></div>' +
'<button class="editor-ws-dropdown-item editor-ws-new" id="editorWsNewBtn">' + '<button class="ext-editor-ws-dropdown-item ext-editor-ws-new" id="editorWsNewBtn">' +
'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' + '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
'New Workspace' + 'New Workspace' +
'</button>' + '</button>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div class="editor-topbar-branch" id="editorBranchBadge" style="display:none;">' + '<div class="ext-editor-topbar-branch" id="editorBranchBadge" style="display:none;">' +
'<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--purple)" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="12" cy="6" r="3"/><line x1="12" y1="9" x2="12" y2="15"/></svg>' + '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--purple)" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="12" cy="6" r="3"/><line x1="12" y1="9" x2="12" y2="15"/></svg>' +
'<span id="editorBranchName" class="editor-topbar-branch-text">main</span>' + '<span id="editorBranchName" class="ext-editor-topbar-branch-text">main</span>' +
'</div>' + '</div>' +
'<div style="flex:1;"></div>' + '<div style="flex:1;"></div>' +
'<button class="icon-btn" id="editorRefreshBtn" title="Refresh files">' + '<button class="icon-btn" id="editorRefreshBtn" title="Refresh files">' +
@@ -151,11 +151,11 @@
function _buildBootstrap() { function _buildBootstrap() {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'editor-bootstrap'; el.className = 'ext-editor-bootstrap';
el.id = 'editorBootstrap'; el.id = 'editorBootstrap';
el.style.display = 'none'; el.style.display = 'none';
el.innerHTML = el.innerHTML =
'<div class="editor-bootstrap-card">' + '<div class="ext-editor-bootstrap-card">' +
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="1.5" style="opacity:0.6;margin-bottom:12px;">' + '<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="1.5" style="opacity:0.6;margin-bottom:12px;">' +
'<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>' + '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>' +
'</svg>' + '</svg>' +
@@ -168,8 +168,8 @@
'<span style="font-size:11px;color:var(--text-3);text-transform:uppercase;">or create new</span>' + '<span style="font-size:11px;color:var(--text-3);text-transform:uppercase;">or create new</span>' +
'<div style="flex:1;height:1px;background:var(--border);"></div>' + '<div style="flex:1;height:1px;background:var(--border);"></div>' +
'</div>' + '</div>' +
'<input type="text" id="editorBootstrapName" class="editor-bootstrap-input" placeholder="Workspace name" value="workspace">' + '<input type="text" id="editorBootstrapName" class="ext-editor-bootstrap-input" placeholder="Workspace name" value="workspace">' +
'<button id="editorBootstrapBtn" class="editor-bootstrap-btn">Create Workspace</button>' + '<button id="editorBootstrapBtn" class="ext-editor-bootstrap-btn">Create Workspace</button>' +
'</div>'; '</div>';
return el; return el;
} }
@@ -218,7 +218,7 @@
} }
workspaces.forEach(ws => { workspaces.forEach(ws => {
const item = document.createElement('button'); const item = document.createElement('button');
item.className = 'editor-ws-dropdown-item' + (ws.id === currentWsId ? ' active' : ''); item.className = 'ext-editor-ws-dropdown-item' + (ws.id === currentWsId ? ' active' : '');
item.textContent = ws.name || ws.id?.slice(0, 8); item.textContent = ws.name || ws.id?.slice(0, 8);
item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; }); item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
listEl.appendChild(item); listEl.appendChild(item);
@@ -243,10 +243,10 @@
listEl.innerHTML = ''; listEl.innerHTML = '';
workspaces.forEach(ws => { workspaces.forEach(ws => {
const item = document.createElement('button'); const item = document.createElement('button');
item.className = 'editor-bootstrap-ws-item'; item.className = 'ext-editor-bootstrap-ws-item';
item.innerHTML = item.innerHTML =
'<span class="editor-bootstrap-ws-name">' + esc(ws.name || ws.id?.slice(0, 8)) + '</span>' + '<span class="ext-editor-bootstrap-ws-name">' + esc(ws.name || ws.id?.slice(0, 8)) + '</span>' +
'<span class="editor-bootstrap-ws-date">' + esc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>'; '<span class="ext-editor-bootstrap-ws-date">' + esc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '</span>';
item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; }); item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
listEl.appendChild(item); listEl.appendChild(item);
}); });

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,74 +1,38 @@
/* ICD Test Runner — Surface Styles */ /* ICD Test Runner — Surface Styles */
#extension-mount { .ext-icd-test-runner-root {
font-family: var(--font, 'DM Sans', sans-serif); font-family: var(--font, 'DM Sans', sans-serif);
} }
#extension-mount h1 { .ext-icd-test-runner-root h1 {
font-family: var(--font, 'DM Sans', sans-serif); font-family: var(--font, 'DM Sans', sans-serif);
letter-spacing: -0.3px; letter-spacing: -0.3px;
} }
#extension-mount table { .ext-icd-test-runner-root table {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
#extension-mount table td, .ext-icd-test-runner-root table td,
#extension-mount table th { .ext-icd-test-runner-root table th {
border-color: var(--border); border-color: var(--border);
} }
#extension-mount table tbody tr:last-child { .ext-icd-test-runner-root table tbody tr:last-child {
border-bottom: none; border-bottom: none;
} }
#extension-mount table tbody tr:hover { .ext-icd-test-runner-root table tbody tr:hover {
background: var(--bg-hover) !important; background: var(--bg-hover) !important;
} }
/* Buttons — override small padding for our use */ /* Buttons — uses kernel sw-btn system, minor overrides for weight */
#extension-mount .btn-primary, .ext-icd-test-runner-root .sw-btn {
#extension-mount .btn-secondary,
#extension-mount .btn-ghost {
font-size: 13px;
padding: 7px 16px;
border-radius: var(--radius, 8px);
cursor: pointer;
font-family: var(--font, 'DM Sans', sans-serif);
font-weight: 600; font-weight: 600;
transition: background var(--transition, 180ms ease), opacity var(--transition, 180ms ease);
}
/* Fallback button styles if platform classes not loaded */
#extension-mount button.btn-primary {
background: var(--accent, #6c9fff);
color: #fff;
border: none;
}
#extension-mount button.btn-primary:hover {
opacity: 0.88;
}
#extension-mount button.btn-secondary {
background: var(--accent-dim, rgba(108,159,255,0.12));
color: var(--accent, #6c9fff);
border: none;
}
#extension-mount button.btn-secondary:hover {
background: var(--accent-dim, rgba(108,159,255,0.2));
}
#extension-mount button.btn-ghost {
background: transparent;
color: var(--text-2, #9898a8);
border: 1px solid var(--border, #2e2e35);
}
#extension-mount button.btn-ghost:hover {
background: var(--bg-hover, #2a2a30);
} }
/* Status dot animation for running state */ /* Status dot animation for running state */
@keyframes pulse-dot { @keyframes ext-icd-test-runner-pulse-dot {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.4; } 50% { opacity: 0.4; }
} }

View File

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

View File

@@ -337,7 +337,7 @@
await T.test('sdk', 'pipe-render', 'Render filter modifies DOM', async function () { await T.test('sdk', 'pipe-render', 'Render filter modifies DOM', async function () {
sw.pipe.render(1, function (ctx) { sw.pipe.render(1, function (ctx) {
if (ctx.element.dataset.sdkRenderTest) { if (ctx.element.dataset.sdkRenderTest) {
ctx.element.classList.add('sdk-render-modified'); ctx.element.classList.add('ext-icd-test-runner-sdk-render-modified');
} }
return ctx; return ctx;
}, { source: '_test-render-dom' }); }, { source: '_test-render-dom' });
@@ -348,7 +348,7 @@
element: el, message: null, element: el, message: null,
channel: { id: 'fake', type: 'direct' } channel: { id: 'fake', type: 'direct' }
}); });
T.assert(el.classList.contains('sdk-render-modified'), 'element should have class'); T.assert(el.classList.contains('ext-icd-test-runner-sdk-render-modified'), 'element should have class');
}); });
await T.test('sdk', 'pipe-render', 'Priority ordering in render chain', async function () { await T.test('sdk', 'pipe-render', 'Priority ordering in render chain', async function () {

View File

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

View File

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

View File

@@ -132,16 +132,16 @@
} }
return html` return html`
<div class="tag-input"> <div class="ext-notes-tag-input">
${(tags || []).map(function(tag) { ${(tags || []).map(function(tag) {
return html` return html`
<span key=${tag} class="tag-pill"> <span key=${tag} class="ext-notes-tag-pill">
${tag} ${tag}
<span class="tag-pill__remove" onClick=${function() { removeTag(tag); }}>×</span> <span class="ext-notes-tag-pill__remove" onClick=${function() { removeTag(tag); }}>×</span>
</span> </span>
`; `;
})} })}
<input ref=${inputRef} class="tag-input__field" type="text" <input ref=${inputRef} class="ext-notes-tag-input__field" type="text"
placeholder=${(tags && tags.length) ? 'Add tag…' : 'Add tags (comma to separate)…'} placeholder=${(tags && tags.length) ? 'Add tag…' : 'Add tags (comma to separate)…'}
value=${input} value=${input}
onInput=${handleInput} onInput=${handleInput}
@@ -149,9 +149,9 @@
onFocus=${function() { if (input.trim()) setShowAC(true); }} onFocus=${function() { if (input.trim()) setShowAC(true); }}
onBlur=${function() { setTimeout(function() { setShowAC(false); }, 150); }} /> onBlur=${function() { setTimeout(function() { setShowAC(false); }, 150); }} />
${showAC && suggestions.length > 0 && html` ${showAC && suggestions.length > 0 && html`
<div class="tag-autocomplete"> <div class="ext-notes-tag-autocomplete">
${suggestions.map(function(s) { ${suggestions.map(function(s) {
return html`<div key=${s} class="tag-autocomplete__item" return html`<div key=${s} class="ext-notes-tag-autocomplete__item"
onClick=${function() { addTag(s); }}>${s}</div>`; onClick=${function() { addTag(s); }}>${s}</div>`;
})} })}
</div> </div>
@@ -169,17 +169,17 @@
if (!tags || tags.length === 0) return null; if (!tags || tags.length === 0) return null;
return html` return html`
<div class="tag-filter"> <div class="ext-notes-tag-filter">
<div class="tag-filter__label"> <div class="ext-notes-tag-filter__label">
<span>Tags</span> <span>Tags</span>
${activeTag && html` ${activeTag && html`
<button class="tag-filter__clear" onClick=${function() { onSelect(null); }}>Clear</button> <button class="ext-notes-tag-filter__clear" onClick=${function() { onSelect(null); }}>Clear</button>
`} `}
</div> </div>
<div class="tag-filter__pills"> <div class="ext-notes-tag-filter__pills">
${tags.map(function(tag) { ${tags.map(function(tag) {
return html` return html`
<span key=${tag} class="tag-pill ${activeTag === tag ? 'tag-pill--active' : ''}" <span key=${tag} class="ext-notes-tag-pill ${activeTag === tag ? 'ext-notes-tag-pill--active' : ''}"
onClick=${function() { onSelect(activeTag === tag ? null : tag); }}> onClick=${function() { onSelect(activeTag === tag ? null : tag); }}>
${tag} ${tag}
</span> </span>
@@ -216,23 +216,23 @@
} }
return html` return html`
<div class="note-card ${active ? 'note-card--active' : ''} ${dragging ? 'note-card--dragging' : ''}" <div class="ext-notes-note-card ${active ? 'ext-notes-note-card--active' : ''} ${dragging ? 'ext-notes-note-card--dragging' : ''}"
onClick=${onClick} onClick=${onClick}
draggable="true" draggable="true"
onDragStart=${handleDragStart} onDragStart=${handleDragStart}
onDragEnd=${handleDragEnd}> onDragEnd=${handleDragEnd}>
<div class="note-card__title"> <div class="ext-notes-note-card__title">
${note.pinned ? html`<span class="note-card__pin">📌</span>` : null} ${note.pinned ? html`<span class="ext-notes-note-card__pin">📌</span>` : null}
${note.title || 'Untitled'} ${note.title || 'Untitled'}
</div> </div>
${note.snippet && html`<div class="note-card__snippet">${note.snippet}</div>`} ${note.snippet && html`<div class="ext-notes-note-card__snippet">${note.snippet}</div>`}
${showTags.length > 0 && html` ${showTags.length > 0 && html`
<div class="note-card__tags"> <div class="ext-notes-note-card__tags">
${showTags.map(function(t) { return html`<span key=${t} class="tag-pill">${t}</span>`; })} ${showTags.map(function(t) { return html`<span key=${t} class="ext-notes-tag-pill">${t}</span>`; })}
${overflow > 0 && html`<span class="tag-pill tag-pill--overflow">+${overflow}</span>`} ${overflow > 0 && html`<span class="ext-notes-tag-pill ext-notes-tag-pill--overflow">+${overflow}</span>`}
</div> </div>
`} `}
${dateStr && html`<div class="note-card__date">${dateStr}</div>`} ${dateStr && html`<div class="ext-notes-note-card__date">${dateStr}</div>`}
</div> </div>
`; `;
} }
@@ -254,10 +254,10 @@
}, [onClose]); }, [onClose]);
return html` return html`
<div ref=${menuRef} class="folder-context-menu" style="top: ${y}px; left: ${x}px"> <div ref=${menuRef} class="ext-notes-folder-context-menu" style="top: ${y}px; left: ${x}px">
<div class="folder-context-menu__item" onClick=${onAddSub}>Add subfolder</div> <div class="ext-notes-folder-context-menu__item" onClick=${onAddSub}>Add subfolder</div>
<div class="folder-context-menu__item" onClick=${onRename}>Rename</div> <div class="ext-notes-folder-context-menu__item" onClick=${onRename}>Rename</div>
<div class="folder-context-menu__item folder-context-menu__item--danger" onClick=${onDelete}>Delete</div> <div class="ext-notes-folder-context-menu__item ext-notes-folder-context-menu__item--danger" onClick=${onDelete}>Delete</div>
</div> </div>
`; `;
} }
@@ -310,25 +310,25 @@
return html` return html`
<div> <div>
<div class="folder-tree__item ${isActive ? 'folder-tree__item--active' : ''} ${dropOver ? 'folder-tree__item--drop-target' : ''}" <div class="ext-notes-folder-tree__item ${isActive ? 'ext-notes-folder-tree__item--active' : ''} ${dropOver ? 'ext-notes-folder-tree__item--drop-target' : ''}"
style="padding-left: ${indent}px" style="padding-left: ${indent}px"
onClick=${function() { onSelect(node.folder.id); }} onClick=${function() { onSelect(node.folder.id); }}
onContextMenu=${handleContext} onContextMenu=${handleContext}
onDragOver=${handleDragOver} onDragOver=${handleDragOver}
onDragLeave=${handleDragLeave} onDragLeave=${handleDragLeave}
onDrop=${handleDrop}> onDrop=${handleDrop}>
<span class="folder-tree__toggle" onClick=${handleToggle}> <span class="ext-notes-folder-tree__toggle" onClick=${handleToggle}>
${hasChildren ? (expanded ? '▼' : '▶') : '·'} ${hasChildren ? (expanded ? '▼' : '▶') : '·'}
</span> </span>
<span class="folder-tree__icon">${isActive ? '📂' : '📁'}</span> <span class="ext-notes-folder-tree__icon">${isActive ? '📂' : '📁'}</span>
${editing ${editing
? html`<input class="folder-tree__edit" value=${editName} ? html`<input class="ext-notes-folder-tree__edit" value=${editName}
onInput=${function(e) { setEditName(e.target.value); }} onInput=${function(e) { setEditName(e.target.value); }}
onBlur=${handleRename} onBlur=${handleRename}
onKeyDown=${function(e) { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setEditing(false); }} onKeyDown=${function(e) { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setEditing(false); }}
onClick=${function(e) { e.stopPropagation(); }} onClick=${function(e) { e.stopPropagation(); }}
ref=${function(el) { if (el) el.focus(); }} />` ref=${function(el) { if (el) el.focus(); }} />`
: html`<span class="folder-tree__name">${node.folder.name}</span>` : html`<span class="ext-notes-folder-tree__name">${node.folder.name}</span>`
} }
</div> </div>
${menu && html`<${FolderContextMenu} x=${menu.x} y=${menu.y} ${menu && html`<${FolderContextMenu} x=${menu.x} y=${menu.y}
@@ -399,18 +399,18 @@
var unfiledDrop = makeDropHandlers(setDropUnfiled); var unfiledDrop = makeDropHandlers(setDropUnfiled);
return html` return html`
<div class="folder-tree"> <div class="ext-notes-folder-tree">
<div class="folder-tree__item ${!activeFolderId && !showUnfiled ? 'folder-tree__item--active' : ''} ${dropAll ? 'folder-tree__item--drop-target' : ''}" <div class="ext-notes-folder-tree__item ${!activeFolderId && !showUnfiled ? 'ext-notes-folder-tree__item--active' : ''} ${dropAll ? 'ext-notes-folder-tree__item--drop-target' : ''}"
onClick=${onSelectAll} onClick=${onSelectAll}
onDragOver=${allDrop.onDragOver} onDragLeave=${allDrop.onDragLeave} onDrop=${allDrop.onDrop}> onDragOver=${allDrop.onDragOver} onDragLeave=${allDrop.onDragLeave} onDrop=${allDrop.onDrop}>
<span class="folder-tree__icon">📋</span> <span class="ext-notes-folder-tree__icon">📋</span>
<span class="folder-tree__name">All Notes</span> <span class="ext-notes-folder-tree__name">All Notes</span>
</div> </div>
<div class="folder-tree__item ${showUnfiled ? 'folder-tree__item--active' : ''} ${dropUnfiled ? 'folder-tree__item--drop-target' : ''}" <div class="ext-notes-folder-tree__item ${showUnfiled ? 'ext-notes-folder-tree__item--active' : ''} ${dropUnfiled ? 'ext-notes-folder-tree__item--drop-target' : ''}"
onClick=${onSelectUnfiled} onClick=${onSelectUnfiled}
onDragOver=${unfiledDrop.onDragOver} onDragLeave=${unfiledDrop.onDragLeave} onDrop=${unfiledDrop.onDrop}> onDragOver=${unfiledDrop.onDragOver} onDragLeave=${unfiledDrop.onDragLeave} onDrop=${unfiledDrop.onDrop}>
<span class="folder-tree__icon">📄</span> <span class="ext-notes-folder-tree__icon">📄</span>
<span class="folder-tree__name">Unfiled</span> <span class="ext-notes-folder-tree__name">Unfiled</span>
</div> </div>
${tree.map(function(node) { ${tree.map(function(node) {
return html`<${FolderNode} key=${node.folder.id} node=${node} depth=${0} return html`<${FolderNode} key=${node.folder.id} node=${node} depth=${0}
@@ -419,7 +419,7 @@
onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder} onDelete=${onDeleteFolder} onCreateSub=${onCreateFolder}
onDropNote=${onDropNote} />`; onDropNote=${onDropNote} />`;
})} })}
<div class="folder-tree__add" onClick=${function() { onCreateFolder(''); }}> <div class="ext-notes-folder-tree__add" onClick=${function() { onCreateFolder(''); }}>
+ New Folder + New Folder
</div> </div>
</div> </div>
@@ -446,18 +446,18 @@
if (!backlinks.length) return null; if (!backlinks.length) return null;
return html` return html`
<div class="backlinks-panel"> <div class="ext-notes-backlinks-panel">
<div class="backlinks-panel__header" onClick=${function() { setCollapsed(!collapsed); }}> <div class="ext-notes-backlinks-panel__header" onClick=${function() { setCollapsed(!collapsed); }}>
<span class="backlinks-panel__toggle">${collapsed ? '▶' : '▼'}</span> <span class="ext-notes-backlinks-panel__toggle">${collapsed ? '▶' : '▼'}</span>
<span>Backlinks</span> <span>Backlinks</span>
<span class="backlinks-panel__count">${backlinks.length}</span> <span class="ext-notes-backlinks-panel__count">${backlinks.length}</span>
</div> </div>
${!collapsed && backlinks.map(function(bl) { ${!collapsed && backlinks.map(function(bl) {
return html` return html`
<div key=${bl.source_id} class="backlinks-panel__item" <div key=${bl.source_id} class="ext-notes-backlinks-panel__item"
onClick=${function() { if (onNavigate) onNavigate(bl.source_id); }}> onClick=${function() { if (onNavigate) onNavigate(bl.source_id); }}>
<span class="backlinks-panel__title">${bl.source_title}</span> <span class="ext-notes-backlinks-panel__title">${bl.source_title}</span>
<span class="backlinks-panel__context">via [[${bl.link_text}]]</span> <span class="ext-notes-backlinks-panel__context">via [[${bl.link_text}]]</span>
</div> </div>
`; `;
})} })}
@@ -547,10 +547,10 @@
function SidebarTabs({ activeTab, onTabChange, noteSelected }) { function SidebarTabs({ activeTab, onTabChange, noteSelected }) {
return html` return html`
<div class="sidebar-tabs"> <div class="ext-ext-notes-sidebar-tabs">
<button class="sidebar-tabs__tab ${activeTab === 'notes' ? 'sidebar-tabs__tab--active' : ''}" <button class="ext-ext-notes-sidebar-tabs__tab ${activeTab === 'notes' ? 'ext-ext-notes-sidebar-tabs__tab--active' : ''}"
onClick=${function() { onTabChange('notes'); }}>Notes</button> onClick=${function() { onTabChange('notes'); }}>Notes</button>
<button class="sidebar-tabs__tab ${activeTab === 'outline' ? 'sidebar-tabs__tab--active' : ''} ${!noteSelected ? 'sidebar-tabs__tab--disabled' : ''}" <button class="ext-ext-notes-sidebar-tabs__tab ${activeTab === 'outline' ? 'ext-ext-notes-sidebar-tabs__tab--active' : ''} ${!noteSelected ? 'ext-ext-notes-sidebar-tabs__tab--disabled' : ''}"
onClick=${function() { if (noteSelected) onTabChange('outline'); }}>Outline</button> onClick=${function() { if (noteSelected) onTabChange('outline'); }}>Outline</button>
</div> </div>
`; `;
@@ -563,14 +563,14 @@
function SidebarOutline({ headings, activeHeadingIdx, onScrollToHeading }) { function SidebarOutline({ headings, activeHeadingIdx, onScrollToHeading }) {
if (!headings || headings.length === 0) { if (!headings || headings.length === 0) {
return html`<div class="sidebar-outline__empty">No headings found</div>`; return html`<div class="ext-ext-notes-sidebar-outline__empty">No headings found</div>`;
} }
return html` return html`
<div class="sidebar-outline"> <div class="ext-ext-notes-sidebar-outline">
${headings.map(function(h, i) { ${headings.map(function(h, i) {
return html` return html`
<div key=${i} class="sidebar-outline__item sidebar-outline__item--h${h.level} ${i === activeHeadingIdx ? 'sidebar-outline__item--active' : ''}" <div key=${i} class="ext-ext-notes-sidebar-outline__item ext-ext-notes-sidebar-outline__item--h${h.level} ${i === activeHeadingIdx ? 'ext-ext-notes-sidebar-outline__item--active' : ''}"
style="padding-left: ${10 + (h.level - 1) * 14}px" style="padding-left: ${10 + (h.level - 1) * 14}px"
onClick=${function() { onScrollToHeading(h); }}> onClick=${function() { onScrollToHeading(h); }}>
${h.text} ${h.text}
@@ -772,8 +772,8 @@
for (var i = 0; i < outgoingLinks.length; i++) { for (var i = 0; i < outgoingLinks.length; i++) {
var link = outgoingLinks[i]; var link = outgoingLinks[i];
if (!link.target_id) { if (!link.target_id) {
var search = 'class="wikilink" data-wikilink="' + _escAttr(link.link_text) + '"'; var search = 'class="ext-notes-wikilink" data-wikilink="' + _escAttr(link.link_text) + '"';
var replace = 'class="wikilink wikilink--unresolved" data-wikilink="' + _escAttr(link.link_text) + '"'; var replace = 'class="ext-notes-wikilink ext-notes-wikilink--unresolved" data-wikilink="' + _escAttr(link.link_text) + '"';
h = h.split(search).join(replace); h = h.split(search).join(replace);
} }
} }
@@ -787,7 +787,7 @@
if (!el || viewMode === 'edit') return; if (!el || viewMode === 'edit') return;
function handleClick(e) { function handleClick(e) {
var target = e.target; var target = e.target;
if (target.classList && target.classList.contains('wikilink')) { if (target.classList && target.classList.contains('ext-notes-wikilink')) {
e.preventDefault(); e.preventDefault();
var linkText = target.getAttribute('data-wikilink'); var linkText = target.getAttribute('data-wikilink');
if (linkText && onNavigateToTitle) onNavigateToTitle(linkText); if (linkText && onNavigateToTitle) onNavigateToTitle(linkText);
@@ -939,44 +939,44 @@
}, [folders]); }, [folders]);
if (!note) { if (!note) {
return html`<div class="notes-editor__empty">Select a note or create a new one</div>`; return html`<div class="ext-notes-editor__empty">Select a note or create a new one</div>`;
} }
// ── Render editor body ───────────────────── // ── Render editor body ─────────────────────
function renderEditorBody() { function renderEditorBody() {
if (viewMode === 'rendered') { if (viewMode === 'rendered') {
return html`<div class="notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`; return html`<div class="ext-notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`;
} }
if (viewMode === 'split') { if (viewMode === 'split') {
var editor = hasCM var editor = hasCM
? html`<div class="notes-editor__cm" ref=${cmContainerRef} />` ? html`<div class="ext-notes-editor__cm" ref=${cmContainerRef} />`
: html`<textarea class="notes-editor__textarea" ref=${textareaRef} : html`<textarea class="ext-notes-editor__textarea" ref=${textareaRef}
value=${body} onInput=${handleBodyChange} value=${body} onInput=${handleBodyChange}
onKeyDown=${handleKeyDown} onKeyDown=${handleKeyDown}
placeholder="Start writing in Markdown…" />`; placeholder="Start writing in Markdown…" />`;
return [ return [
editor, editor,
html`<div class="notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />` html`<div class="ext-notes-preview" ref=${previewRef} dangerouslySetInnerHTML=${{ __html: previewHtml }} />`
]; ];
} }
// edit mode // edit mode
if (hasCM) { if (hasCM) {
return html`<div class="notes-editor__cm" ref=${cmContainerRef} />`; return html`<div class="ext-notes-editor__cm" ref=${cmContainerRef} />`;
} }
return html`<textarea class="notes-editor__textarea" ref=${textareaRef} return html`<textarea class="ext-notes-editor__textarea" ref=${textareaRef}
value=${body} onInput=${handleBodyChange} value=${body} onInput=${handleBodyChange}
onKeyDown=${handleKeyDown} onKeyDown=${handleKeyDown}
placeholder="Start writing in Markdown…" />`; placeholder="Start writing in Markdown…" />`;
} }
return html` return html`
<div class="notes-editor"> <div class="ext-notes-editor">
<div class="notes-editor__header"> <div class="ext-notes-editor__header">
<input class="notes-editor__title-input" type="text" <input class="ext-notes-editor__title-input" type="text"
value=${title} onInput=${handleTitleChange} value=${title} onInput=${handleTitleChange}
placeholder="Note title" /> placeholder="Note title" />
<div class="notes-editor__actions"> <div class="ext-notes-editor__actions">
<select class="notes-editor__folder-select" <select class="ext-notes-editor__folder-select"
value=${note.folder_id || ''} value=${note.folder_id || ''}
onChange=${async function(e) { onChange=${async function(e) {
await api.post('/notes/move', { note_id: note.id, folder_id: e.target.value }); await api.post('/notes/move', { note_id: note.id, folder_id: e.target.value });
@@ -987,28 +987,28 @@
return html`<option key=${f.id} value=${f.id}>${f.label}</option>`; return html`<option key=${f.id} value=${f.id}>${f.label}</option>`;
})} })}
</select> </select>
<span class=${dirty ? 'notes-saved notes-saved--dirty' : 'notes-saved'}> <span class=${dirty ? 'ext-notes-saved ext-notes-saved--dirty' : 'ext-notes-saved'}>
${saving ? 'Saving…' : (dirty ? 'Unsaved' : 'Saved')} ${saving ? 'Saving…' : (dirty ? 'Unsaved' : 'Saved')}
</span> </span>
<button class="notes-toggle" onClick=${function() { <button class="ext-notes-toggle" onClick=${function() {
var next = viewMode === 'rendered' ? 'edit' : viewMode === 'edit' ? 'split' : 'rendered'; var next = viewMode === 'rendered' ? 'edit' : viewMode === 'edit' ? 'split' : 'rendered';
_modeStore.set('editor_mode', next); _modeStore.set('editor_mode', next);
setViewMode(next); setViewMode(next);
}}> }}>
${viewMode === 'rendered' ? 'Edit' : viewMode === 'edit' ? 'Split' : 'Read'} ${viewMode === 'rendered' ? 'Edit' : viewMode === 'edit' ? 'Split' : 'Read'}
</button> </button>
<button class="notes-btn" onClick=${handleExport} title="Export as .md">Export</button> <button class="ext-notes-btn" onClick=${handleExport} title="Export as .md">Export</button>
<button class="notes-btn" onClick=${handlePin} <button class="ext-notes-btn" onClick=${handlePin}
title=${note.pinned ? 'Unpin' : 'Pin'}> title=${note.pinned ? 'Unpin' : 'Pin'}>
${note.pinned ? '📌' : '📍'} ${note.pinned ? '📌' : '📍'}
</button> </button>
<button class="notes-btn notes-btn--danger" onClick=${handleArchive} <button class="ext-notes-btn ext-notes-btn--danger" onClick=${handleArchive}
title="Archive">🗑</button> title="Archive">🗑</button>
</div> </div>
</div> </div>
<${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} /> <${TagInput} tags=${noteTags} allTags=${allTags} onChange=${handleTagsChange} />
<div class="notes-editor__content"> <div class="ext-notes-editor__content">
<div class="notes-editor__body ${viewMode === 'split' ? 'notes-editor__body--split' : ''}"> <div class="ext-notes-editor__body ${viewMode === 'split' ? 'ext-notes-editor__body--split' : ''}">
${renderEditorBody()} ${renderEditorBody()}
</div> </div>
</div> </div>
@@ -1405,17 +1405,17 @@
}, []); }, []);
if (loading) { if (loading) {
return html`<div class="graph-pane"><div class="graph-empty"><${Spinner} size="md" /></div></div>`; return html`<div class="ext-notes-graph-pane"><div class="ext-notes-graph-empty"><${Spinner} size="md" /></div></div>`;
} }
if (!graphData || graphData.nodes.length === 0) { if (!graphData || graphData.nodes.length === 0) {
return html`<div class="graph-pane"><div class="graph-empty">No notes to graph. Create notes with [[wikilinks]] to see connections.</div></div>`; return html`<div class="ext-notes-graph-pane"><div class="ext-notes-graph-empty">No notes to graph. Create notes with [[wikilinks]] to see connections.</div></div>`;
} }
var adj = graphData.adj; var adj = graphData.adj;
return html` return html`
<div class="graph-pane"> <div class="ext-notes-graph-pane">
<div class="graph-toolbar"> <div class="ext-notes-graph-toolbar">
<label> <label>
<input type="checkbox" checked=${hideOrphans} <input type="checkbox" checked=${hideOrphans}
onChange=${function(e) { setHideOrphans(e.target.checked); }} /> onChange=${function(e) { setHideOrphans(e.target.checked); }} />
@@ -1430,14 +1430,14 @@
onMouseUp=${handleMouseUp} onMouseUp=${handleMouseUp}
onMouseLeave=${handleMouseUp} /> onMouseLeave=${handleMouseUp} />
${hoveredNode && html` ${hoveredNode && html`
<div class="graph-tooltip" style=${{ left: (mousePos.x + 14) + 'px', top: (mousePos.y - 10) + 'px' }}> <div class="ext-notes-graph-tooltip" style=${{ left: (mousePos.x + 14) + 'px', top: (mousePos.y - 10) + 'px' }}>
<div class="graph-tooltip__title">${hoveredNode.title}</div> <div class="ext-notes-graph-tooltip__title">${hoveredNode.title}</div>
${hoveredNode.tags && hoveredNode.tags.length > 0 && html` ${hoveredNode.tags && hoveredNode.tags.length > 0 && html`
<div class="graph-tooltip__tags"> <div class="ext-notes-graph-tooltip__tags">
${hoveredNode.tags.map(function(t) { return html`<span class="tag-pill" key=${t}>${t}</span>`; })} ${hoveredNode.tags.map(function(t) { return html`<span class="ext-notes-tag-pill" key=${t}>${t}</span>`; })}
</div> </div>
`} `}
<div class="graph-tooltip__edges">${(adj[hoveredNode.id] ? adj[hoveredNode.id].size : 0)} connections</div> <div class="ext-notes-graph-tooltip__edges">${(adj[hoveredNode.id] ? adj[hoveredNode.id].size : 0)} connections</div>
</div> </div>
`} `}
</div> </div>
@@ -1736,8 +1736,8 @@
<${Button} variant=${showGraph ? 'primary' : 'secondary'} size="sm" <${Button} variant=${showGraph ? 'primary' : 'secondary'} size="sm"
onClick=${function() { setShowGraph(!showGraph); }}>Graph<//> onClick=${function() { setShowGraph(!showGraph); }}>Graph<//>
<//> <//>
<div class="notes-app"> <div class="ext-notes-app">
<div class="notes-sidebar"> <div class="ext-notes-sidebar">
<${SidebarTabs} activeTab=${sidebarTab} onTabChange=${setSidebarTab} noteSelected=${!!activeNote} /> <${SidebarTabs} activeTab=${sidebarTab} onTabChange=${setSidebarTab} noteSelected=${!!activeNote} />
${sidebarTab === 'notes' && html` ${sidebarTab === 'notes' && html`
<${FolderTree} folders=${folders} <${FolderTree} folders=${folders}
@@ -1749,14 +1749,14 @@
onDeleteFolder=${handleDeleteFolder} onDeleteFolder=${handleDeleteFolder}
onDropNote=${handleDropNote} /> onDropNote=${handleDropNote} />
<${TagFilter} tags=${allTags} activeTag=${activeTag} onSelect=${handleTagFilter} /> <${TagFilter} tags=${allTags} activeTag=${activeTag} onSelect=${handleTagFilter} />
<div class="notes-search"> <div class="ext-notes-search">
<input type="text" placeholder="Search notes…" <input type="text" placeholder="Search notes…"
value=${searchTerm} onInput=${handleSearch} /> value=${searchTerm} onInput=${handleSearch} />
</div> </div>
<div class="notes-list"> <div class="ext-notes-list">
${loading && html`<div class="notes-list__loading"><${Spinner} size="md" /></div>`} ${loading && html`<div class="ext-notes-list__loading"><${Spinner} size="md" /></div>`}
${!loading && notes.length === 0 && html` ${!loading && notes.length === 0 && html`
<div class="notes-list__empty"> <div class="ext-notes-list__empty">
${searchTerm ? 'No matching notes' : (activeTag ? 'No notes with tag "' + activeTag + '"' : 'No notes yet. Click + New Note to start.')} ${searchTerm ? 'No matching notes' : (activeTag ? 'No notes with tag "' + activeTag + '"' : 'No notes yet. Click + New Note to start.')}
</div> </div>
`} `}

View File

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

View File

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

View File

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

View File

@@ -58,25 +58,25 @@
mount.innerHTML = ''; mount.innerHTML = '';
// Header // Header
var header = el('div', { className: 'sdkr-header' }, [ var header = el('div', { className: 'ext-sdk-test-runner-header' }, [
el('h1', {}, 'SDK Test Runner'), el('h1', {}, 'SDK Test Runner'),
el('span', { className: 'sdkr-version' }, 'v' + (T.manifest.version || '?')) el('span', { className: 'ext-sdk-test-runner-version' }, 'v' + (T.manifest.version || '?'))
]); ]);
mount.appendChild(header); mount.appendChild(header);
// Description // Description
mount.appendChild(el('p', { className: 'sdkr-desc' }, mount.appendChild(el('p', { className: 'ext-sdk-test-runner-desc' },
'Dual-path validation: SDK call → raw ICD fetch. ' + 'Dual-path validation: SDK call → raw ICD fetch. ' +
'SDK fail + ICD pass = SDK bug. Both fail = ICD bug.')); 'SDK fail + ICD pass = SDK bug. Both fail = ICD bug.'));
// ── Fixtures Panel ── // ── Fixtures Panel ──
var fixturePanel = el('div', { className: 'sdkr-fixtures' }); var fixturePanel = el('div', { className: 'ext-sdk-test-runner-fixtures' });
var fixRow = el('div', { className: 'sdkr-fixture-row' }); var fixRow = el('div', { className: 'ext-sdk-test-runner-fixture-row' });
fixRow.appendChild(el('span', { className: 'sdkr-fixture-label' }, 'FIXTURES:')); fixRow.appendChild(el('span', { className: 'ext-sdk-test-runner-fixture-label' }, 'FIXTURES:'));
// Provider dropdown // Provider dropdown
var provSelect = el('select', { className: 'sdkr-select' }); var provSelect = el('select', { className: 'ext-sdk-test-runner-select' });
provSelect.appendChild(el('option', { value: '' }, '— provider —')); provSelect.appendChild(el('option', { value: '' }, '— provider —'));
['openrouter', 'openai', 'anthropic', 'venice'].forEach(function (p) { ['openrouter', 'openai', 'anthropic', 'venice'].forEach(function (p) {
provSelect.appendChild(el('option', { value: p }, p)); provSelect.appendChild(el('option', { value: p }, p));
@@ -85,18 +85,18 @@
// API key input // API key input
var keyInput = el('input', { var keyInput = el('input', {
type: 'password', className: 'sdkr-input', type: 'password', className: 'ext-sdk-test-runner-input',
placeholder: 'API key (optional — enables model tests)' placeholder: 'API key (optional — enables model tests)'
}); });
fixRow.appendChild(keyInput); fixRow.appendChild(keyInput);
// Fixture status // Fixture status
var fixStatus = el('span', { className: 'sdkr-fixture-status' }, ''); var fixStatus = el('span', { className: 'ext-sdk-test-runner-fixture-status' }, '');
fixRow.appendChild(fixStatus); fixRow.appendChild(fixStatus);
fixturePanel.appendChild(fixRow); fixturePanel.appendChild(fixRow);
var fixNote = el('div', { className: 'sdkr-fixture-note' }, var fixNote = el('div', { className: 'ext-sdk-test-runner-fixture-note' },
'Provide a provider + API key to unlock embedding/model tests. ' + 'Provide a provider + API key to unlock embedding/model tests. ' +
'Without a key, those tests are skipped. Key is never stored.'); 'Without a key, those tests are skipped. Key is never stored.');
fixturePanel.appendChild(fixNote); fixturePanel.appendChild(fixNote);
@@ -107,16 +107,16 @@
T._fixtureInput = { provSelect: provSelect, keyInput: keyInput, statusEl: fixStatus }; T._fixtureInput = { provSelect: provSelect, keyInput: keyInput, statusEl: fixStatus };
// Controls row // Controls row
var controlRow = el('div', { className: 'sdkr-controls' }); var controlRow = el('div', { className: 'ext-sdk-test-runner-controls' });
// Domain filter checkboxes // Domain filter checkboxes
var filterBox = el('div', { className: 'sdkr-filters' }); var filterBox = el('div', { className: 'ext-sdk-test-runner-filters' });
filterBox.appendChild(el('span', { className: 'sdkr-filter-label' }, 'Domains: ')); filterBox.appendChild(el('span', { className: 'ext-sdk-test-runner-filter-label' }, 'Domains: '));
var domainNames = Object.keys(T.domains).sort(); var domainNames = Object.keys(T.domains).sort();
var domainChecks = {}; var domainChecks = {};
domainNames.forEach(function (name) { domainNames.forEach(function (name) {
var label = el('label', { className: 'sdkr-check' }); var label = el('label', { className: 'ext-sdk-test-runner-check' });
var cb = el('input', { type: 'checkbox', checked: 'checked' }); var cb = el('input', { type: 'checkbox', checked: 'checked' });
domainChecks[name] = cb; domainChecks[name] = cb;
label.appendChild(cb); label.appendChild(cb);
@@ -126,11 +126,11 @@
controlRow.appendChild(filterBox); controlRow.appendChild(filterBox);
// Buttons // Buttons
var btnRow = el('div', { className: 'sdkr-btn-row' }); var btnRow = el('div', { className: 'ext-sdk-test-runner-btn-row' });
var runBtn = el('button', { className: 'sdkr-btn sdkr-btn-primary', onClick: onRun }, 'Run All'); var runBtn = el('button', { className: 'ext-sdk-test-runner-btn ext-sdk-test-runner-btn-primary', onClick: onRun }, 'Run All');
var abortBtn = el('button', { className: 'sdkr-btn sdkr-btn-danger', onClick: function () { T.abort(); } }, 'Abort'); var abortBtn = el('button', { className: 'ext-sdk-test-runner-btn ext-sdk-test-runner-btn-danger', onClick: function () { T.abort(); } }, 'Abort');
abortBtn.style.display = 'none'; abortBtn.style.display = 'none';
var exportBtn = el('button', { className: 'sdkr-btn', onClick: onExport }, 'Export JSON'); var exportBtn = el('button', { className: 'ext-sdk-test-runner-btn', onClick: onExport }, 'Export JSON');
btnRow.appendChild(runBtn); btnRow.appendChild(runBtn);
btnRow.appendChild(abortBtn); btnRow.appendChild(abortBtn);
btnRow.appendChild(exportBtn); btnRow.appendChild(exportBtn);
@@ -138,12 +138,12 @@
mount.appendChild(controlRow); mount.appendChild(controlRow);
// Summary bar // Summary bar
var summaryBar = el('div', { className: 'sdkr-summary' }); var summaryBar = el('div', { className: 'ext-sdk-test-runner-summary' });
mount.appendChild(summaryBar); mount.appendChild(summaryBar);
// Results table // Results table
var tableWrap = el('div', { className: 'sdkr-table-wrap' }); var tableWrap = el('div', { className: 'ext-sdk-test-runner-table-wrap' });
var table = el('table', { className: 'sdkr-table' }); var table = el('table', { className: 'ext-sdk-test-runner-table' });
var thead = el('thead', {}, [ var thead = el('thead', {}, [
el('tr', {}, [ el('tr', {}, [
el('th', {}, '#'), el('th', {}, '#'),
@@ -176,7 +176,7 @@
]; ];
items.forEach(function (item) { items.forEach(function (item) {
var badge = el('span', { var badge = el('span', {
className: 'sdkr-badge', className: 'ext-sdk-test-runner-badge',
style: { style: {
background: item[1] > 0 ? COLORS[item[0]] : 'var(--bg2, #333)', background: item[1] > 0 ? COLORS[item[0]] : 'var(--bg2, #333)',
color: item[1] > 0 ? '#fff' : 'var(--text3, #888)' color: item[1] > 0 ? '#fff' : 'var(--text3, #888)'
@@ -184,7 +184,7 @@
}, LABELS[item[0]] + ': ' + item[1]); }, LABELS[item[0]] + ': ' + item[1]);
summaryBar.appendChild(badge); summaryBar.appendChild(badge);
}); });
summaryBar.appendChild(el('span', { className: 'sdkr-badge', summaryBar.appendChild(el('span', { className: 'ext-sdk-test-runner-badge',
style: { background: 'var(--bg2, #333)' } style: { background: 'var(--bg2, #333)' }
}, 'Total: ' + total)); }, 'Total: ' + total));
} }
@@ -197,19 +197,19 @@
if (entry.rawErr) detail += (detail ? ' | ' : '') + 'ICD: ' + esc(entry.rawErr); if (entry.rawErr) detail += (detail ? ' | ' : '') + 'ICD: ' + esc(entry.rawErr);
if (entry.note) detail += (detail ? ' | ' : '') + esc(entry.note); if (entry.note) detail += (detail ? ' | ' : '') + esc(entry.note);
var row = el('tr', { className: 'sdkr-row sdkr-row-' + v.toLowerCase() }, [ var row = el('tr', { className: 'ext-sdk-test-runner-row ext-sdk-test-runner-row-' + v.toLowerCase() }, [
el('td', {}, String(idx)), el('td', {}, String(idx)),
el('td', {}, esc(entry.domain)), el('td', {}, esc(entry.domain)),
el('td', {}, esc(entry.group)), el('td', {}, esc(entry.group)),
el('td', { className: 'sdkr-test-name' }, esc(entry.name)), el('td', { className: 'ext-sdk-test-runner-test-name' }, esc(entry.name)),
el('td', {}, [ el('td', {}, [
el('span', { el('span', {
className: 'sdkr-verdict', className: 'ext-sdk-test-runner-verdict',
style: { color: COLORS[v] || '#fff' } style: { color: COLORS[v] || '#fff' }
}, LABELS[v] || v) }, LABELS[v] || v)
]), ]),
el('td', { className: 'sdkr-ms' }, String(entry.durationMs)), el('td', { className: 'ext-sdk-test-runner-ms' }, String(entry.durationMs)),
el('td', { className: 'sdkr-detail' }, detail) el('td', { className: 'ext-sdk-test-runner-detail' }, detail)
]); ]);
tbody.appendChild(row); tbody.appendChild(row);
updateSummary(); updateSummary();

View File

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

View File

@@ -88,7 +88,7 @@
return html` return html`
<${Dialog} open onClose=${onClose} title=${task ? 'Edit Task' : 'New Task'}> <${Dialog} open onClose=${onClose} title=${task ? 'Edit Task' : 'New Task'}>
<form onSubmit=${handleSubmit} class="task-form"> <form onSubmit=${handleSubmit} class="ext-tasks-form">
${error && html`<${Banner} variant="danger" text=${error} />`} ${error && html`<${Banner} variant="danger" text=${error} />`}
<${FormField} label="Title" required> <${FormField} label="Title" required>
<input type="text" value=${title} onInput=${e => setTitle(e.target.value)} <input type="text" value=${title} onInput=${e => setTitle(e.target.value)}
@@ -98,7 +98,7 @@
<textarea rows="3" value=${desc} onInput=${e => setDesc(e.target.value)} <textarea rows="3" value=${desc} onInput=${e => setDesc(e.target.value)}
placeholder="Details (optional)" /> placeholder="Details (optional)" />
<//> <//>
<div class="task-form__row"> <div class="ext-tasks-form__row">
<${FormField} label="Status"> <${FormField} label="Status">
<select value=${status} onChange=${e => setStatus(e.target.value)}> <select value=${status} onChange=${e => setStatus(e.target.value)}>
${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)} ${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)}
@@ -110,7 +110,7 @@
</select> </select>
<//> <//>
</div> </div>
<div class="task-form__row"> <div class="ext-tasks-form__row">
<${FormField} label="Due Date"> <${FormField} label="Due Date">
<input type="date" value=${dueDate} onInput=${e => setDueDate(e.target.value)} /> <input type="date" value=${dueDate} onInput=${e => setDueDate(e.target.value)} />
<//> <//>
@@ -119,7 +119,7 @@
placeholder="comma-separated" /> placeholder="comma-separated" />
<//> <//>
</div> </div>
<div class="task-form__actions"> <div class="ext-tasks-form__actions">
<${Button} variant="ghost" type="button" onClick=${onClose}>Cancel<//> <${Button} variant="ghost" type="button" onClick=${onClose}>Cancel<//>
<${Button} variant="primary" type="submit" disabled=${saving}> <${Button} variant="primary" type="submit" disabled=${saving}>
${saving ? 'Saving…' : (task ? 'Update' : 'Create')} ${saving ? 'Saving…' : (task ? 'Update' : 'Create')}
@@ -155,45 +155,45 @@
} }
return html` return html`
<div class="task-list"> <div class="ext-tasks-list">
<div class="task-list__filters"> <div class="ext-tasks-list__filters">
<select value=${filter} onChange=${e => setFilter(e.target.value)} class="task-filter"> <select value=${filter} onChange=${e => setFilter(e.target.value)} class="ext-tasks-filter">
<option value="all">All</option> <option value="all">All</option>
${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)} ${STATUSES.map(s => html`<option value=${s}>${STATUS_LABELS[s] || s}</option>`)}
</select> </select>
<span class="task-list__count">${filtered.length} task${filtered.length !== 1 ? 's' : ''}</span> <span class="ext-tasks-list__count">${filtered.length} task${filtered.length !== 1 ? 's' : ''}</span>
</div> </div>
${loading && html`<div class="task-list__loading"><${Spinner} size="md" /></div>`} ${loading && html`<div class="ext-tasks-list__loading"><${Spinner} size="md" /></div>`}
${!loading && filtered.length === 0 && html` ${!loading && filtered.length === 0 && html`
<div class="task-list__empty">No tasks${filter !== 'all' ? ' matching filter' : ''}. Create one to get started.</div> <div class="ext-tasks-list__empty">No tasks${filter !== 'all' ? ' matching filter' : ''}. Create one to get started.</div>
`} `}
${!loading && filtered.map(function(t) { ${!loading && filtered.map(function(t) {
return html` return html`
<div class="task-card" key=${t.id}> <div class="ext-tasks-card" key=${t.id}>
<div class="task-card__header"> <div class="ext-tasks-card__header">
<span class="task-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}"> <span class="ext-tasks-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">
</span> </span>
<span class="task-card__title" onClick=${() => onEdit(t)}>${t.title}</span> <span class="ext-tasks-card__title" onClick=${() => onEdit(t)}>${t.title}</span>
<span class="task-card__status task-card__status--${t.status}"> <span class="ext-tasks-card__status ext-tasks-card__status--${t.status}">
${STATUS_LABELS[t.status] || t.status} ${STATUS_LABELS[t.status] || t.status}
</span> </span>
</div> </div>
${t.description && html` ${t.description && html`
<div class="task-card__desc">${t.description}</div> <div class="ext-tasks-card__desc">${t.description}</div>
`} `}
<div class="task-card__footer"> <div class="ext-tasks-card__footer">
${t.due_date && html`<span class="task-card__due">Due: ${t.due_date}</span>`} ${t.due_date && html`<span class="ext-tasks-card__due">Due: ${t.due_date}</span>`}
${t.tags && html`<span class="task-card__tags">${t.tags}</span>`} ${t.tags && html`<span class="ext-tasks-card__tags">${t.tags}</span>`}
<div class="task-card__actions"> <div class="ext-tasks-card__actions">
${t.status !== 'done' && html` ${t.status !== 'done' && html`
<button class="task-btn task-btn--sm" onClick=${() => handleQuickStatus(t.id, 'done')} <button class="ext-tasks-btn ext-tasks-btn--sm" onClick=${() => handleQuickStatus(t.id, 'done')}
title="Mark done">✓</button> title="Mark done">✓</button>
`} `}
<button class="task-btn task-btn--sm task-btn--danger" onClick=${() => handleDelete(t.id)} <button class="ext-tasks-btn ext-tasks-btn--sm ext-tasks-btn--danger" onClick=${() => handleDelete(t.id)}
title="Delete">×</button> title="Delete">×</button>
</div> </div>
</div> </div>
@@ -216,25 +216,25 @@
} }
return html` return html`
<div class="task-board"> <div class="ext-tasks-board">
${loading && html`<div class="task-list__loading"><${Spinner} size="md" /></div>`} ${loading && html`<div class="ext-tasks-list__loading"><${Spinner} size="md" /></div>`}
${!loading && STATUSES.map(function(status) { ${!loading && STATUSES.map(function(status) {
var col = items.filter(function(t) { return t.status === status; }); var col = items.filter(function(t) { return t.status === status; });
return html` return html`
<div class="task-board__col" key=${status}> <div class="ext-tasks-board__col" key=${status}>
<div class="task-board__col-header"> <div class="ext-tasks-board__col-header">
<span class="task-board__col-title">${STATUS_LABELS[status] || status}</span> <span class="ext-tasks-board__col-title">${STATUS_LABELS[status] || status}</span>
<span class="task-board__col-count">${col.length}</span> <span class="ext-tasks-board__col-count">${col.length}</span>
</div> </div>
<div class="task-board__col-body"> <div class="ext-tasks-board__col-body">
${col.map(function(t) { ${col.map(function(t) {
return html` return html`
<div class="task-board__card" key=${t.id} onClick=${() => onEdit(t)}> <div class="ext-tasks-board__card" key=${t.id} onClick=${() => onEdit(t)}>
<div class="task-board__card-title"> <div class="ext-tasks-board__card-title">
<span class="task-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">●</span> <span class="ext-tasks-card__priority" style="color:${PRIORITY_COLORS[t.priority] || 'var(--text-3)'}">●</span>
${t.title} ${t.title}
</div> </div>
${t.due_date && html`<div class="task-board__card-due">Due: ${t.due_date}</div>`} ${t.due_date && html`<div class="ext-tasks-board__card-due">Due: ${t.due_date}</div>`}
</div> </div>
`; `;
})} })}
@@ -307,22 +307,22 @@
var Topbar = sw.shell?.Topbar; var Topbar = sw.shell?.Topbar;
return html` return html`
<div class="tasks-app"> <div class="ext-tasks-app">
${Topbar ? html` ${Topbar ? html`
<${Topbar} title="Tasks"> <${Topbar} title="Tasks">
${stats && html`<span class="tasks-stats">${stats.total || 0} total</span>`} ${stats && html`<span class="ext-tasks-stats">${stats.total || 0} total</span>`}
<div class="tasks-view-tabs"> <div class="ext-tasks-view-tabs">
${tabs.map(function(tab) { ${tabs.map(function(tab) {
return html`<button key=${tab.id} return html`<button key=${tab.id}
class="tasks-view-tab ${view === tab.id ? 'tasks-view-tab--active' : ''}" class="ext-tasks-view-tab ${view === tab.id ? 'ext-tasks-view-tab--active' : ''}"
onClick=${() => setView(tab.id)}>${tab.label}</button>`; onClick=${() => setView(tab.id)}>${tab.label}</button>`;
})} })}
</div> </div>
<${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//> <${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//>
<//> <//>
` : html` ` : html`
<div class="tasks-header"> <div class="ext-tasks-header">
<h1 class="tasks-title">Tasks</h1> <h1 class="ext-tasks-title">Tasks</h1>
<${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//> <${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task<//>
</div> </div>
`} `}
@@ -381,7 +381,7 @@
}, []); }, []);
if (count === null) return null; if (count === null) return null;
return html`<span class="task-status-widget" title="Open tasks">${count} open task${count !== 1 ? 's' : ''}</span>`; return html`<span class="ext-tasks-status-widget" title="Open tasks">${count} open task${count !== 1 ? 's' : ''}</span>`;
} }
sw.slots.register('statusbar', { sw.slots.register('statusbar', {

View File

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

View File

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

View File

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

View File

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

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

@@ -26,11 +26,15 @@ func (p *BuiltinProvider) Authenticate(c *gin.Context, stores store.Stores) (*Re
var req struct { var req struct {
Login string `json:"login" binding:"required"` Login string `json:"login" binding:"required"`
Password string `json:"password" binding:"required"` Password string `json:"password" binding:"required"`
KeepLogin bool `json:"keep_login"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
return nil, err return nil, err
} }
// Stash keep_login in context for generateTokens
c.Set("keep_login", req.KeepLogin)
user, err := stores.Users.GetByLogin(c.Request.Context(), req.Login) user, err := stores.Users.GetByLogin(c.Request.Context(), req.Login)
if err != nil { if err != nil {
return nil, ErrInvalidCreds return nil, ErrInvalidCreds

View File

@@ -58,7 +58,9 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
token_hash TEXT NOT NULL UNIQUE, token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
revoked_at TIMESTAMPTZ revoked_at TIMESTAMPTZ,
keep_login BOOLEAN DEFAULT FALSE,
last_activity_at TIMESTAMPTZ
); );
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);

View File

@@ -43,7 +43,9 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
token_hash TEXT NOT NULL UNIQUE, token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL, expires_at TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
revoked_at TEXT revoked_at TEXT,
keep_login INTEGER DEFAULT 0,
last_activity_at TEXT
); );
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);

View File

@@ -72,7 +72,7 @@ func (h *AuthHandler) Register(c *gin.Context) {
return return
} }
tokens, err := h.generateTokens(result.User) tokens, err := h.generateTokens(result.User, false)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return return
@@ -94,7 +94,13 @@ func (h *AuthHandler) Login(c *gin.Context) {
h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID) h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID)
tokens, err := h.generateTokens(result.User) // Check for keep_login flag from request body.
// The provider already consumed the body, so we read from gin context
// if the provider stashed it there; otherwise default to false.
keepLogin, _ := c.Get("keep_login")
keep, _ := keepLogin.(bool)
tokens, err := h.generateTokens(result.User, keep)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return return
@@ -113,22 +119,32 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
} }
tokenHash := hashToken(req.RefreshToken) tokenHash := hashToken(req.RefreshToken)
userID, err := h.stores.Users.GetRefreshToken(c.Request.Context(), tokenHash) info, err := h.stores.Users.GetRefreshTokenInfo(c.Request.Context(), tokenHash)
if err != nil { if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
return return
} }
// Idle timeout check
sc := LoadSessionConfig(c.Request.Context(), h.stores.GlobalConfig)
if sc.IdleTimeout > 0 && info.LastActivityAt != nil {
if time.Since(*info.LastActivityAt) > sc.IdleTimeout {
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
c.JSON(http.StatusUnauthorized, gin.H{"error": "session expired due to inactivity"})
return
}
}
// Revoke the used token (rotate) // Revoke the used token (rotate)
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash) h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
user, err := h.stores.Users.GetByID(c.Request.Context(), userID) user, err := h.stores.Users.GetByID(c.Request.Context(), info.UserID)
if err != nil || !user.IsActive { if err != nil || !user.IsActive {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"})
return return
} }
tokens, err := h.generateTokens(user) tokens, err := h.generateTokens(user, info.KeepLogin)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return return
@@ -159,6 +175,19 @@ func (h *AuthHandler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "logged out"}) c.JSON(http.StatusOK, gin.H{"message": "logged out"})
} }
// Activity updates last_activity_at on the caller's active refresh tokens.
// Called by the client SDK on user interaction (debounced, max 1/min).
// POST /api/v1/auth/activity (requires auth middleware)
func (h *AuthHandler) Activity(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
h.stores.Users.UpdateRefreshTokenActivity(c.Request.Context(), userID.(string))
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// ── OIDC Flow ────────────────────────────── // ── OIDC Flow ──────────────────────────────
// OIDCLogin initiates the authorization code flow by redirecting to the IdP. // OIDCLogin initiates the authorization code flow by redirecting to the IdP.
@@ -271,7 +300,7 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
} }
// Issue internal JWT // Issue internal JWT
tokens, err := h.generateTokens(result.User) tokens, err := h.generateTokens(result.User, false)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return return
@@ -286,8 +315,14 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
refreshToken, _ := tokens["refresh_token"].(string) refreshToken, _ := tokens["refresh_token"].(string)
userJSON, _ := json.Marshal(tokens["user"]) userJSON, _ := json.Marshal(tokens["user"])
// Set page-auth cookie too (for SSR middleware) // Set page-auth cookie too (for SSR middleware).
c.SetCookie("arm_token", accessToken, 900, "/", "", false, false) // MaxAge matches refresh token lifetime so the cookie
// survives until JS can proactively refresh the access token.
refreshExpiresIn, _ := tokens["refresh_expires_in"].(int)
if refreshExpiresIn <= 0 {
refreshExpiresIn = 604800
}
c.SetCookie("arm_token", accessToken, refreshExpiresIn, "/", "", false, false)
// Base64-encode the token payload for the fragment // Base64-encode the token payload for the fragment
payload := fmt.Sprintf(`{"access_token":"%s","refresh_token":"%s","user":%s}`, payload := fmt.Sprintf(`{"access_token":"%s","refresh_token":"%s","user":%s}`,
@@ -298,13 +333,15 @@ func (h *AuthHandler) OIDCCallback(c *gin.Context) {
c.Redirect(http.StatusFound, dest) c.Redirect(http.StatusFound, dest)
} }
func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) { func (h *AuthHandler) generateTokens(user *models.User, keepLogin bool) (gin.H, error) {
// Access token (15 min) sc := LoadSessionConfig(context.Background(), h.stores.GlobalConfig)
// Access token
accessClaims := Claims{ accessClaims := Claims{
UserID: user.ID, UserID: user.ID,
Email: user.Email, Email: user.Email,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(sc.AccessTokenTTL)),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
ID: uuid.New().String(), ID: uuid.New().String(),
}, },
@@ -315,12 +352,17 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
return nil, err return nil, err
} }
// Refresh token (7 days) // Refresh token — keepLogin=false caps at 24h (session-length)
refreshTTL := sc.RefreshTokenTTL
if !keepLogin && refreshTTL > 24*time.Hour {
refreshTTL = 24 * time.Hour
}
refreshRaw := uuid.New().String() refreshRaw := uuid.New().String()
refreshHash := hashToken(refreshRaw) refreshHash := hashToken(refreshRaw)
expiresAt := time.Now().Add(7 * 24 * time.Hour) expiresAt := time.Now().Add(refreshTTL)
if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt); err != nil { if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt, keepLogin); err != nil {
log.Printf("warn: failed to store refresh token: %v", err) log.Printf("warn: failed to store refresh token: %v", err)
} }
@@ -328,7 +370,8 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
"access_token": accessString, "access_token": accessString,
"refresh_token": refreshRaw, "refresh_token": refreshRaw,
"token_type": "Bearer", "token_type": "Bearer",
"expires_in": 900, "expires_in": int(sc.AccessTokenTTL.Seconds()),
"refresh_expires_in": int(refreshTTL.Seconds()),
"user": gin.H{ "user": gin.H{
"id": user.ID, "id": user.ID,
"username": user.Username, "username": user.Username,

View File

@@ -81,3 +81,38 @@ func (h *PresenceHandler) SearchUsers(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{"data": results}) c.JSON(http.StatusOK, gin.H{"data": results})
} }
// ResolveUsers returns identity records for a batch of user IDs.
// GET /api/v1/users/resolve?ids=uuid1,uuid2,...
func (h *PresenceHandler) ResolveUsers(c *gin.Context) {
raw := strings.TrimSpace(c.Query("ids"))
if raw == "" {
c.JSON(http.StatusOK, gin.H{"data": map[string]interface{}{}})
return
}
ids := make([]string, 0)
for _, id := range strings.Split(raw, ",") {
id = strings.TrimSpace(id)
if id != "" {
ids = append(ids, id)
}
}
if len(ids) == 0 {
c.JSON(http.StatusOK, gin.H{"data": map[string]interface{}{}})
return
}
results, err := h.stores.Users.ResolveByIDs(c.Request.Context(), ids)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "resolve failed"})
return
}
// Build map keyed by user ID for O(1) client-side lookups
m := make(map[string]interface{}, len(results))
for _, u := range results {
m[u.ID] = u
}
c.JSON(http.StatusOK, gin.H{"data": m})
}

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() wfScanner.Start()
defer wfScanner.Stop() defer wfScanner.Stop()
// ── Activity ping (authenticated, rate-limited) ──
// Client SDK calls this on user interaction (debounced, max 1/min)
// to update last_activity_at for idle-timeout tracking.
activityGroup := api.Group("/auth")
activityGroup.Use(middleware.Auth(cfg, stores.Users, userCache))
activityGroup.POST("/activity", authH.Activity)
// ── Protected routes ──────────────────── // ── Protected routes ────────────────────
protected := api.Group("") protected := api.Group("")
protected.Use(middleware.Auth(cfg, stores.Users, userCache)) protected.Use(middleware.Auth(cfg, stores.Users, userCache))
@@ -467,8 +474,9 @@ func main() {
protected.POST("/presence/heartbeat", presence.Heartbeat) protected.POST("/presence/heartbeat", presence.Heartbeat)
protected.GET("/presence", presence.Query) protected.GET("/presence", presence.Query)
// User search // User search & resolve
protected.GET("/users/search", presence.SearchUsers) protected.GET("/users/search", presence.SearchUsers)
protected.GET("/users/resolve", presence.ResolveUsers)
// Workflows // Workflows
wfH := handlers.NewWorkflowHandler(stores) wfH := handlers.NewWorkflowHandler(stores)

View File

@@ -142,18 +142,43 @@ func parseAndValidateJWT(tokenString string, jwtSecret string) (*Claims, bool) {
return claims, true return claims, true
} }
// parseJWTIgnoringExpiry parses a JWT and validates the signature but
// tolerates an expired token. Used by page-auth middleware so the Go
// template can render the page shell even when the access token has
// lapsed — the Preact SDK will refresh the token client-side.
// Returns (claims, signatureValid). A tampered or unsigned token
// returns (nil, false).
func parseJWTIgnoringExpiry(tokenString string, jwtSecret string) (*Claims, bool) {
claims := &Claims{}
_, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(jwtSecret), nil
}, jwt.WithoutClaimsValidation())
if err != nil {
return nil, false
}
return claims, true
}
// UserIDFromCookie extracts the user ID from the arm_token cookie without // UserIDFromCookie extracts the user ID from the arm_token cookie without
// requiring authentication. Returns "" if no valid token is found. // requiring authentication. Returns "" if no valid token is found.
// Used by unauthenticated routes that want optional user context. // Tolerates expired tokens (signature must be valid) so that user preferences
// still apply even when the access token has lapsed.
func UserIDFromCookie(c *gin.Context, jwtSecret string) string { func UserIDFromCookie(c *gin.Context, jwtSecret string) string {
cookie, err := c.Cookie("arm_token") cookie, err := c.Cookie("arm_token")
if err != nil || cookie == "" { if err != nil || cookie == "" {
return "" return ""
} }
claims, ok := parseAndValidateJWT(cookie, jwtSecret) claims, ok := parseAndValidateJWT(cookie, jwtSecret)
if !ok {
// Accept expired-but-signed token for optional user context
claims, ok = parseJWTIgnoringExpiry(cookie, jwtSecret)
if !ok { if !ok {
return "" return ""
} }
}
return claims.UserID return claims.UserID
} }

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,11 +50,18 @@ func AuthOrRedirect(cfg *config.Config, users store.UserStore, cache *UserStatus
return return
} }
// Try strict validation first (token not expired).
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret) claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
if !ok {
// Fallback: accept an expired-but-signed token so the page
// shell can render and the Preact SDK can refresh client-side.
// A tampered/unsigned token still gets rejected.
claims, ok = parseJWTIgnoringExpiry(tokenString, cfg.JWTSecret)
if !ok { if !ok {
redirectToLogin(c, loginPath) redirectToLogin(c, loginPath)
return return
} }
}
if claims.UserID == "" { if claims.UserID == "" {
redirectToLogin(c, loginPath) redirectToLogin(c, loginPath)

View File

@@ -27,12 +27,9 @@
<link rel="stylesheet" href="{{.BasePath}}/surfaces/{{.Surface}}/css/main.css?v={{.Version}}"> <link rel="stylesheet" href="{{.BasePath}}/surfaces/{{.Surface}}/css/main.css?v={{.Version}}">
{{end}} {{end}}
<style> <style>
:root { body { margin: 0; background: var(--bg); color: var(--text); display: flex; flex-direction: column; height: 100vh; height: 100dvh; overflow: hidden; padding: env(safe-area-inset-top, 0) env(safe-area-inset-right, 0) env(safe-area-inset-bottom, 0) env(safe-area-inset-left, 0); }
--banner-h: 28px;
}
body { margin: 0; background: var(--bg); color: var(--text); display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
.surface { flex: 1; min-height: 0; overflow: hidden; } .surface { flex: 1; min-height: 0; overflow: hidden; }
.surface-inner { width: 100%; height: 100%; transform-origin: top left; } .surface-inner { width: 100%; height: 100%; overflow: hidden; }
.banner { flex-shrink: 0; } .banner { flex-shrink: 0; }
</style> </style>
<meta name="theme-color" content="#0e0e10" id="metaThemeColor"> <meta name="theme-color" content="#0e0e10" id="metaThemeColor">
@@ -52,7 +49,7 @@
} catch(e) {} } catch(e) {}
})(); })();
// Early appearance — apply scale from localStorage on all surfaces. // Early appearance — apply scale from localStorage on all surfaces.
// Scale uses transform on .surface-inner so shell stays fixed, content fills viewport. // CSS zoom reflows layout correctly (no getBoundingClientRect hacks needed).
(function() { (function() {
try { try {
var p = JSON.parse(localStorage.getItem('sb-appearance') || '{}'); var p = JSON.parse(localStorage.getItem('sb-appearance') || '{}');
@@ -60,7 +57,7 @@
var s = p.scale / 100; var s = p.scale / 100;
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('surfaceInner'); var el = document.getElementById('surfaceInner');
if (el) { el.style.transform = 'scale('+s+')'; el.style.transformOrigin = 'top left'; el.style.width = (100/s)+'%'; el.style.height = (100/s)+'%'; } if (el) { el.style.zoom = s; }
}); });
} }
} catch(e) {} } catch(e) {}
@@ -69,9 +66,12 @@
</head> </head>
<body data-surface="{{.Surface}}" data-base-path="{{.BasePath}}"> <body data-surface="{{.Surface}}" data-base-path="{{.BasePath}}">
{{if .Banner.Visible}} {{if .Banner.Visible}}
<div class="banner banner-top active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};height:var(--banner-h);line-height:var(--banner-h);text-align:center;font-size:12px;font-weight:600;overflow:hidden;"> <div class="banner banner-top active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};padding:6px 12px;text-align:center;font-size:12px;font-weight:600;line-height:1.3;">
{{.Banner.Text}} {{.Banner.Text}}
</div> </div>
<script>
(function(){ var b=document.querySelector('.banner-top.active'); if(b) document.documentElement.style.setProperty('--banner-top-height',b.offsetHeight+'px'); })();
</script>
{{end}} {{end}}
{{if .Message.Visible}} {{if .Message.Visible}}
@@ -101,9 +101,12 @@
{{end}} {{end}}
{{if .Banner.Visible}} {{if .Banner.Visible}}
<div class="banner banner-bottom active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};height:var(--banner-h);line-height:var(--banner-h);text-align:center;font-size:12px;font-weight:600;overflow:hidden;"> <div class="banner banner-bottom active" style="background:{{.Banner.Background}};color:{{.Banner.Color}};padding:6px 12px;text-align:center;font-size:12px;font-weight:600;line-height:1.3;">
{{.Banner.Text}} {{.Banner.Text}}
</div> </div>
<script>
(function(){ var b=document.querySelector('.banner-bottom.active'); if(b) document.documentElement.style.setProperty('--banner-bottom-height',b.offsetHeight+'px'); })();
</script>
{{end}} {{end}}
<script nonce="{{.CSPNonce}}"> <script nonce="{{.CSPNonce}}">

View File

@@ -16,7 +16,7 @@
{{if .Multiple}}multiple{{end}} {{if .Multiple}}multiple{{end}}
{{if .Accept}}accept="{{.Accept}}"{{end}} {{if .Accept}}accept="{{.Accept}}"{{end}}
style="display:none"> style="display:none">
<button type="button" class="btn-small btn-primary" <button type="button" class="sw-btn sw-btn--primary sw-btn--sm"
onclick="document.getElementById('{{.FieldName}}Input').click()"> onclick="document.getElementById('{{.FieldName}}Input').click()">
Choose files… Choose files…
</button> </button>

View File

@@ -7,7 +7,6 @@
<title>Armature</title> <title>Armature</title>
<link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}"> <link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}">
<link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}"> <link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{.BasePath}}/css/variables.css?v={{.Version}}"> <link rel="stylesheet" href="{{.BasePath}}/css/variables.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/sw-login.css?v={{.Version}}"> <link rel="stylesheet" href="{{.BasePath}}/css/sw-login.css?v={{.Version}}">
<style> <style>

View File

@@ -13,6 +13,6 @@
{{define "surface-extension"}} {{define "surface-extension"}}
<div id="extension-surface" class="extension-surface" <div id="extension-surface" class="extension-surface"
data-surface-id="{{.Surface}}"> data-surface-id="{{.Surface}}">
<div id="extension-mount" class="extension-mount"></div> <div id="extension-mount" class="extension-mount" data-ext="{{.Surface}}"></div>
</div> </div>
{{end}} {{end}}

View File

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

View File

@@ -13,7 +13,7 @@
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--font); background: var(--bg); color: var(--text); } body { font-family: var(--font); background: var(--bg); color: var(--text); }
.wf-shell { display: flex; flex-direction: column; height: 100vh; } .wf-shell { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
.wf-header { .wf-header {
padding: 16px 24px; padding: 16px 24px;

View File

@@ -1355,6 +1355,42 @@ paths:
items: items:
type: object type: object
/api/v1/users/resolve:
get:
summary: Resolve user identities by ID
operationId: resolveUsers
tags: [Presence]
parameters:
- name: ids
in: query
required: true
description: Comma-separated user IDs (max 100)
schema:
type: string
responses:
'200':
description: Map of user ID to identity record
content:
application/json:
schema:
type: object
properties:
data:
type: object
additionalProperties:
type: object
properties:
id:
type: string
username:
type: string
display_name:
type: string
handle:
type: string
avatar_url:
type: string
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# Notifications # Notifications
# ────────────────────────────────────────────── # ──────────────────────────────────────────────

View File

@@ -13,6 +13,13 @@ import (
// ErrSystemGroup is returned when attempting to delete a system-sourced group. // ErrSystemGroup is returned when attempting to delete a system-sourced group.
var ErrSystemGroup = errors.New("system groups cannot be deleted") var ErrSystemGroup = errors.New("system groups cannot be deleted")
// RefreshTokenInfo holds extended metadata for a refresh token row.
type RefreshTokenInfo struct {
UserID string
KeepLogin bool
LastActivityAt *time.Time
}
// ========================================= // =========================================
// STORES — Data Access Layer // STORES — Data Access Layer
// ========================================= // =========================================
@@ -79,11 +86,13 @@ type UserStore interface {
ListActiveUserIDs(ctx context.Context) ([]string, error) ListActiveUserIDs(ctx context.Context) ([]string, error)
// Refresh tokens // Refresh tokens
CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error
GetRefreshToken(ctx context.Context, tokenHash string) (userID string, err error) GetRefreshToken(ctx context.Context, tokenHash string) (userID string, err error)
GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*RefreshTokenInfo, error)
RevokeRefreshToken(ctx context.Context, tokenHash string) error RevokeRefreshToken(ctx context.Context, tokenHash string) error
RevokeAllRefreshTokens(ctx context.Context, userID string) error RevokeAllRefreshTokens(ctx context.Context, userID string) error
CleanExpiredTokens(ctx context.Context) error CleanExpiredTokens(ctx context.Context) error
UpdateRefreshTokenActivity(ctx context.Context, userID string) error
// ── CS1 additions ── // ── CS1 additions ──
@@ -94,6 +103,10 @@ type UserStore interface {
// Excludes the calling user. Max 20 results. // Excludes the calling user. Max 20 results.
SearchActive(ctx context.Context, excludeUserID, query string) ([]UserSearchResult, error) SearchActive(ctx context.Context, excludeUserID, query string) ([]UserSearchResult, error)
// ResolveByIDs returns lightweight identity records for the given user IDs.
// Missing IDs are silently omitted. Max 100 IDs.
ResolveByIDs(ctx context.Context, ids []string) ([]UserSearchResult, error)
// ── CS2 additions ── // ── CS2 additions ──
// CountAll returns the total number of users. // CountAll returns the total number of users.
@@ -123,6 +136,7 @@ type UserSearchResult struct {
Username string `json:"username"` Username string `json:"username"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
Handle string `json:"handle"` Handle string `json:"handle"`
AvatarURL string `json:"avatar_url,omitempty"`
} }
// ========================================= // =========================================

View File

@@ -142,8 +142,10 @@ func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
// ── Refresh Tokens ────────────────────────── // ── Refresh Tokens ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error {
_, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`, userID, tokenHash, expiresAt) _, err := DB.ExecContext(ctx,
`INSERT INTO refresh_tokens (user_id, token_hash, expires_at, keep_login, last_activity_at) VALUES ($1, $2, $3, $4, NOW())`,
userID, tokenHash, expiresAt, keepLogin)
return err return err
} }
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) { func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
@@ -151,6 +153,17 @@ func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (stri
err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`, tokenHash).Scan(&userID) err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`, tokenHash).Scan(&userID)
return userID, err return userID, err
} }
func (s *UserStore) GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*store.RefreshTokenInfo, error) {
var info store.RefreshTokenInfo
err := DB.QueryRowContext(ctx,
`SELECT user_id, COALESCE(keep_login, false), last_activity_at
FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`,
tokenHash).Scan(&info.UserID, &info.KeepLogin, &info.LastActivityAt)
if err != nil {
return nil, err
}
return &info, nil
}
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error { func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash) _, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash)
return err return err
@@ -163,6 +176,12 @@ func (s *UserStore) CleanExpiredTokens(ctx context.Context) error {
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'") _, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'")
return err return err
} }
func (s *UserStore) UpdateRefreshTokenActivity(ctx context.Context, userID string) error {
_, err := DB.ExecContext(ctx,
"UPDATE refresh_tokens SET last_activity_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL",
userID)
return err
}
// ── Internal ──────────────────────────────── // ── Internal ────────────────────────────────
@@ -231,6 +250,47 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
return results, rows.Err() return results, rows.Err()
} }
// ResolveByIDs returns lightweight identity records for the given user IDs.
func (s *UserStore) ResolveByIDs(ctx context.Context, ids []string) ([]store.UserSearchResult, error) {
if len(ids) == 0 {
return []store.UserSearchResult{}, nil
}
if len(ids) > 100 {
ids = ids[:100]
}
// Build $1,$2,... placeholders
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i, id := range ids {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
q := `SELECT id, username, COALESCE(display_name, '') AS display_name,
COALESCE(handle, '') AS handle, COALESCE(avatar_url, '') AS avatar_url
FROM users WHERE id IN (` + strings.Join(placeholders, ",") + `)`
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []store.UserSearchResult
for rows.Next() {
var u store.UserSearchResult
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle, &u.AvatarURL); err != nil {
continue
}
results = append(results, u)
}
if results == nil {
results = []store.UserSearchResult{}
}
return results, rows.Err()
}
// ── CS2 additions ───────────────────────────────────────────── // ── CS2 additions ─────────────────────────────────────────────
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error { func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {

View File

@@ -148,9 +148,10 @@ func (s *UserStore) ListActiveUserIDs(ctx context.Context) ([]string, error) {
// ── Refresh Tokens ────────────────────────── // ── Refresh Tokens ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time, keepLogin bool) error {
_, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)`, _, err := DB.ExecContext(ctx,
store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt)) `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, keep_login, last_activity_at) VALUES (?, ?, ?, ?, ?, datetime('now'))`,
store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt), keepLogin)
return err return err
} }
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) { func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
@@ -158,6 +159,23 @@ func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (stri
err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`, tokenHash).Scan(&userID) err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`, tokenHash).Scan(&userID)
return userID, err return userID, err
} }
func (s *UserStore) GetRefreshTokenInfo(ctx context.Context, tokenHash string) (*store.RefreshTokenInfo, error) {
var info store.RefreshTokenInfo
var lastAct *string
err := DB.QueryRowContext(ctx,
`SELECT user_id, COALESCE(keep_login, 0), last_activity_at
FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`,
tokenHash).Scan(&info.UserID, &info.KeepLogin, &lastAct)
if err != nil {
return nil, err
}
if lastAct != nil {
if t, err := time.Parse(timeFmt, *lastAct); err == nil {
info.LastActivityAt = &t
}
}
return &info, nil
}
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error { func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
_, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash) _, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash)
return err return err
@@ -170,6 +188,12 @@ func (s *UserStore) CleanExpiredTokens(ctx context.Context) error {
_, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-30 days')") _, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-30 days')")
return err return err
} }
func (s *UserStore) UpdateRefreshTokenActivity(ctx context.Context, userID string) error {
_, err := DB.ExecContext(ctx,
"UPDATE refresh_tokens SET last_activity_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL",
userID)
return err
}
// ── Internal ──────────────────────────────── // ── Internal ────────────────────────────────
@@ -238,6 +262,47 @@ func (s *UserStore) SearchActive(ctx context.Context, excludeUserID, query strin
return results, rows.Err() return results, rows.Err()
} }
// ResolveByIDs returns lightweight identity records for the given user IDs.
func (s *UserStore) ResolveByIDs(ctx context.Context, ids []string) ([]store.UserSearchResult, error) {
if len(ids) == 0 {
return []store.UserSearchResult{}, nil
}
if len(ids) > 100 {
ids = ids[:100]
}
// Build ?,?,... placeholders
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
q := `SELECT id, username, COALESCE(display_name, '') AS display_name,
COALESCE(handle, '') AS handle, COALESCE(avatar_url, '') AS avatar_url
FROM users WHERE id IN (` + strings.Join(placeholders, ",") + `)`
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []store.UserSearchResult
for rows.Next() {
var u store.UserSearchResult
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle, &u.AvatarURL); err != nil {
continue
}
results = append(results, u)
}
if results == nil {
results = []store.UserSearchResult{}
}
return results, rows.Err()
}
// ── CS2 additions ───────────────────────────────────────────── // ── CS2 additions ─────────────────────────────────────────────
func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error { func (s *UserStore) MergeSettings(ctx context.Context, userID string, patch []byte) error {

View File

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

View File

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

View File

@@ -8,7 +8,7 @@
position: fixed; inset: 0; background: var(--overlay); position: fixed; inset: 0; background: var(--overlay);
display: none; align-items: center; justify-content: center; display: none; align-items: center; justify-content: center;
z-index: 1000; backdrop-filter: blur(2px); z-index: 1000; backdrop-filter: blur(2px);
padding: 2rem; /* keeps modal away from viewport edges at any zoom */ padding: var(--sp-8); /* keeps modal away from viewport edges at any zoom */
} }
.modal-overlay.active { display: flex; } .modal-overlay.active { display: flex; }
@@ -23,25 +23,25 @@
.modal-header { .modal-header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid var(--border); padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border);
} }
.modal-header h2 { font-size: 16px; font-weight: 600; } .modal-header h2 { font-size: 16px; font-weight: 600; }
.modal-close { .modal-close {
background: none; border: none; color: var(--text-3); font-size: 18px; background: none; border: none; color: var(--text-3); font-size: 18px;
cursor: pointer; padding: 2px 6px; border-radius: 4px; cursor: pointer; padding: 2px 6px; border-radius: var(--radius-sm);
transition: color var(--transition); transition: color var(--transition);
} }
.modal-close:hover { color: var(--text); } .modal-close:hover { color: var(--text); }
.modal-body { flex: 1; overflow-y: auto; padding: 16px 20px; } @media (max-width: 768px) {
.modal-close { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
}
.modal-body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5); }
.modal-footer { .modal-footer {
padding: 12px 20px; border-top: 1px solid var(--border); padding: var(--sp-3) var(--sp-5); border-top: 1px solid var(--border);
display: flex; justify-content: flex-end; gap: 0.5rem; display: flex; justify-content: flex-end; gap: var(--sp-2);
} }
.settings-section { margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); } .avatar-upload-row { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-4); }
.settings-section:last-child { border-bottom: none; margin-bottom: 0; }
.settings-section h3 { font-size: 12px; font-weight: 600; color: var(--text-3); margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.06em; }
.avatar-upload-row { display: flex; align-items: center; gap: 14px; margin-bottom: 1rem; }
.avatar-preview { .avatar-preview {
width: 56px; height: 56px; border-radius: 50%; background: var(--bg-raised); width: 56px; height: 56px; border-radius: 50%; background: var(--bg-raised);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
@@ -49,7 +49,7 @@
overflow: hidden; border: 2px solid var(--border); overflow: hidden; border: 2px solid var(--border);
} }
.avatar-preview img { width: 100%; height: 100%; object-fit: cover; } .avatar-preview img { width: 100%; height: 100%; object-fit: cover; }
.avatar-actions { display: flex; flex-direction: column; gap: 4px; } .avatar-actions { display: flex; flex-direction: column; gap: var(--sp-1); }
.avatar-actions .form-hint { font-size: 11px; margin-left: 0; } .avatar-actions .form-hint { font-size: 11px; margin-left: 0; }
.avatar-preview-sm { width: 42px; height: 42px; font-size: 18px; } .avatar-preview-sm { width: 42px; height: 42px; font-size: 18px; }
@@ -59,7 +59,7 @@
border-bottom: 1px solid var(--border); background: var(--bg-raised); border-bottom: 1px solid var(--border); background: var(--bg-raised);
} }
.modal-tabs { .modal-tabs {
display: flex; padding: 0 16px; display: flex; padding: 0 var(--sp-4);
border-bottom: 1px solid var(--border); background: var(--bg-raised); border-bottom: 1px solid var(--border); background: var(--bg-raised);
overflow-x: auto; overflow-y: hidden; overflow-x: auto; overflow-y: hidden;
scrollbar-width: none; -ms-overflow-style: none; scrollbar-width: none; -ms-overflow-style: none;
@@ -86,7 +86,7 @@
/* Settings tabs */ /* Settings tabs */
.settings-tabs { gap: 0; } .settings-tabs { gap: 0; }
.settings-tabs .settings-tab { .settings-tabs .settings-tab {
padding: 10px 14px; background: none; border: none; border-radius: 0; padding: var(--sp-3) var(--sp-3); background: none; border: none; border-radius: 0;
color: var(--text-3); cursor: pointer; font-size: 13px; font-family: var(--font); color: var(--text-3); cursor: pointer; font-size: 13px; font-family: var(--font);
border-bottom: 2px solid transparent; transition: color var(--transition); border-bottom: 2px solid transparent; transition: color var(--transition);
outline: none; -webkit-appearance: none; appearance: none; outline: none; -webkit-appearance: none; appearance: none;
@@ -97,19 +97,19 @@
.settings-notice { .settings-notice {
background: rgba(234,179,8,0.08); border: 1px solid var(--warning-dim); background: rgba(234,179,8,0.08); border: 1px solid var(--warning-dim);
border-radius: var(--radius); padding: 10px 14px; font-size: 13px; border-radius: var(--radius); padding: var(--sp-3) var(--sp-3); font-size: 13px;
color: var(--warning); display: flex; align-items: center; gap: 8px; color: var(--warning); display: flex; align-items: center; gap: var(--sp-2);
margin-top: 1rem; margin-top: 1rem;
} }
/* Providers */ /* Providers */
.provider-row { .provider-row {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 8px 0; border-bottom: 1px solid var(--border); padding: var(--sp-2) 0; border-bottom: 1px solid var(--border);
} }
.provider-name { font-weight: 500; font-size: 13px; } .provider-name { font-weight: 500; font-size: 13px; }
.provider-meta { font-size: 11px; color: var(--text-3); margin-left: 8px; } .provider-meta { font-size: 11px; color: var(--text-3); margin-left: var(--sp-2); }
.provider-actions { display: flex; align-items: center; gap: 8px; } .provider-actions { display: flex; align-items: center; gap: var(--sp-2); }
/* Admin panel (fullscreen) */ /* Admin panel (fullscreen) */
.admin-panel { .admin-panel {
@@ -124,7 +124,7 @@
} }
.admin-back { .admin-back {
background: none; border: none; color: var(--text-3); cursor: pointer; background: none; border: none; color: var(--text-3); cursor: pointer;
font-size: 14px; padding: 4px 10px; border-radius: 4px; font-family: var(--font); font-size: 14px; padding: 4px 10px; border-radius: var(--radius-sm); font-family: var(--font);
} }
.admin-back:hover { color: var(--accent); background: var(--bg-raised); } .admin-back:hover { color: var(--accent); background: var(--bg-raised); }
.admin-title { font-size: 16px; font-weight: 600; margin-left: 1rem; flex: 1; } .admin-title { font-size: 16px; font-weight: 600; margin-left: 1rem; flex: 1; }
@@ -147,18 +147,18 @@
.admin-sidebar { .admin-sidebar {
width: 160px; flex-shrink: 0; border-right: 1px solid var(--border); width: 160px; flex-shrink: 0; border-right: 1px solid var(--border);
background: var(--bg-surface); padding: 0.75rem 0; overflow-y: auto; background: var(--bg-surface); padding: var(--sp-3) 0; overflow-y: auto;
} }
.admin-sidebar button { .admin-sidebar button {
display: block; width: 100%; text-align: left; background: none; border: none; display: block; width: 100%; text-align: left; background: none; border: none;
padding: 8px 20px; color: var(--text-3); cursor: pointer; padding: var(--sp-2) var(--sp-5); color: var(--text-3); cursor: pointer;
font-size: 13px; font-family: var(--font); border-left: 3px solid transparent; font-size: 13px; font-family: var(--font); border-left: 3px solid transparent;
transition: color 0.1s, background 0.1s; transition: color 0.1s, background 0.1s;
} }
.admin-sidebar button:hover { color: var(--text-2); background: var(--bg-raised); } .admin-sidebar button:hover { color: var(--text-2); background: var(--bg-raised); }
.admin-sidebar button.active { color: var(--accent); border-left-color: var(--accent); background: var(--bg-raised); } .admin-sidebar button.active { color: var(--accent); border-left-color: var(--accent); background: var(--bg-raised); }
.admin-main { flex: 1; overflow-y: auto; padding: 1.5rem 2rem; } .admin-main { flex: 1; overflow-y: auto; padding: var(--sp-6) var(--sp-8); }
.admin-section-content { /* replaces .admin-tab-content */ } .admin-section-content { /* replaces .admin-tab-content */ }
/* Admin shared */ /* Admin shared */
@@ -172,14 +172,14 @@
.admin-tab:hover { color: var(--text-2); } .admin-tab:hover { color: var(--text-2); }
.admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); } .admin-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.admin-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 1rem; } .admin-toolbar { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-4); }
.admin-hint { font-size: 12px; color: var(--text-3); } .admin-hint { font-size: 12px; color: var(--text-3); }
.admin-inline-form { .admin-inline-form {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
padding: 1rem; margin-bottom: 1rem; padding: var(--sp-4); margin-bottom: var(--sp-4);
} }
.ext-edit-form { .ext-edit-form {
background: var(--bg); padding: 12px 0; background: var(--bg); padding: var(--sp-3) 0;
} }
.ext-edit-form textarea { .ext-edit-form textarea {
resize: vertical; min-height: 60px; resize: vertical; min-height: 60px;
@@ -194,17 +194,17 @@
.admin-user-info { flex: 1; text-align: left; min-width: 0; } .admin-user-info { flex: 1; text-align: left; min-width: 0; }
.admin-user-email { font-size: 12px; color: var(--text-3); margin-top: 2px; } .admin-user-email { font-size: 12px; color: var(--text-3); margin-top: 2px; }
.admin-user-actions { display: flex; gap: 6px; flex-shrink: 0; } .admin-user-actions { display: flex; gap: 6px; flex-shrink: 0; }
.admin-user-actions button { background: none; border: 1px solid var(--border); color: var(--text-2); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; min-width: 68px; text-align: center; } .admin-user-actions button { background: none; border: 1px solid var(--border); color: var(--text-2); border-radius: var(--radius-sm); padding: 4px 10px; font-size: 12px; cursor: pointer; min-width: 68px; text-align: center; }
.admin-user-actions button:hover { border-color: var(--border-light); color: var(--text); } .admin-user-actions button:hover { border-color: var(--border-light); color: var(--text); }
.admin-user-actions .btn-danger { color: var(--danger); } .admin-user-actions .sw-btn--danger { color: var(--danger); }
.admin-user-actions .btn-danger:hover { border-color: var(--danger); } .admin-user-actions .sw-btn--danger:hover { border-color: var(--danger); }
.inline-select { background: var(--bg-surface); border: 1px solid var(--border); color: var(--text-2); border-radius: 4px; padding: 3px 8px; font-size: 12px; cursor: pointer; } .inline-select { background: var(--bg-surface); border: 1px solid var(--border); color: var(--text-2); border-radius: var(--radius-sm); padding: 3px 8px; font-size: 12px; cursor: pointer; }
.admin-model-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; min-height: 44px; } .admin-model-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) 0; border-bottom: 1px solid var(--border); font-size: 14px; min-height: 44px; }
.admin-model-row .model-name { flex: 1; font-weight: 500; } .admin-model-row .model-name { flex: 1; font-weight: 500; }
.admin-model-row .model-caps-inline { display: flex; gap: 3px; } .admin-model-row .model-caps-inline { display: flex; gap: 3px; }
.admin-model-row .provider-meta { min-width: 80px; text-align: right; color: var(--text-3); font-size: 12px; } .admin-model-row .provider-meta { min-width: 80px; text-align: right; color: var(--text-3); font-size: 12px; }
.admin-model-toggle { background: none; border: 1px solid var(--border); border-radius: 4px; padding: 4px 10px; font-size: 12px; cursor: pointer; color: var(--text-2); min-width: 82px; text-align: center; } .admin-model-toggle { background: none; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 10px; font-size: 12px; cursor: pointer; color: var(--text-2); min-width: 82px; text-align: center; }
.admin-model-toggle:hover { border-color: var(--accent); color: var(--accent); } .admin-model-toggle:hover { border-color: var(--accent); color: var(--accent); }
.admin-model-toggle.enabled { border-color: var(--success); color: var(--success); } .admin-model-toggle.enabled { border-color: var(--success); color: var(--success); }
.admin-model-toggle.team { border-color: var(--accent); color: var(--accent); } .admin-model-toggle.team { border-color: var(--accent); color: var(--accent); }
@@ -212,7 +212,7 @@
.model-list-item.model-hidden .model-name { text-decoration: line-through; } .model-list-item.model-hidden .model-name { text-decoration: line-through; }
.admin-provider-row { .admin-provider-row {
display: flex; align-items: center; gap: 12px; padding: 10px 0; display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border); font-size: 14px; border-bottom: 1px solid var(--border); font-size: 14px;
} }
.admin-provider-row .provider-name { font-weight: 500; flex: 1; } .admin-provider-row .provider-name { font-weight: 500; flex: 1; }
@@ -222,7 +222,7 @@
.admin-provider-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; } .admin-provider-row .btn-delete { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 2px 6px; }
.admin-provider-row .btn-delete:hover { color: var(--danger); } .admin-provider-row .btn-delete:hover { color: var(--danger); }
.admin-persona-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; } .admin-persona-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) 0; border-bottom: 1px solid var(--border); font-size: 14px; }
.admin-persona-row .persona-info { flex: 1; min-width: 0; } .admin-persona-row .persona-info { flex: 1; min-width: 0; }
.persona-row-avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; vertical-align: middle; margin-right: 6px; } .persona-row-avatar { width: 22px; height: 22px; border-radius: 50%; object-fit: cover; vertical-align: middle; margin-right: 6px; }
.persona-row-icon { margin-right: 4px; } .persona-row-icon { margin-right: 4px; }
@@ -233,10 +233,10 @@
.admin-persona-row .btn-delete:hover { color: var(--danger); } .admin-persona-row .btn-delete:hover { color: var(--danger); }
/* Stats cards */ /* Stats cards */
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; } .stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: var(--sp-3); }
.stat-card { .stat-card {
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px 16px; text-align: center; padding: var(--sp-3) var(--sp-4); text-align: center;
} }
.stat-card .stat-value { font-size: 1.6rem; font-weight: 700; color: var(--text); line-height: 1.2; } .stat-card .stat-value { font-size: 1.6rem; font-weight: 700; color: var(--text); line-height: 1.2; }
.stat-card .stat-label { font-size: 0.78rem; color: var(--text-3); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.04em; } .stat-card .stat-label { font-size: 0.78rem; color: var(--text-3); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.04em; }
@@ -283,10 +283,10 @@
.model-list-item .model-provider { font-size: 12px; color: var(--text-3); } .model-list-item .model-provider { font-size: 12px; color: var(--text-3); }
.model-list-item .model-caps-inline { display: flex; gap: 3px; } .model-list-item .model-caps-inline { display: flex; gap: 3px; }
.admin-approve-form { padding: 10px 12px; margin: -4px 0 8px; background: var(--glass); border-radius: 0 0 8px 8px; border-top: 1px solid var(--border); } .admin-approve-form { padding: 10px 12px; margin: -4px 0 8px; background: var(--glass); border-radius: 0 0 var(--radius) var(--radius); border-top: 1px solid var(--border); }
.admin-approve-form .checkbox-label { display: block; margin: 2px 0; } .admin-approve-form .checkbox-label { display: block; margin: 2px 0; }
.btn-approve { background: var(--success-dim); color: var(--success-light); border: 1px solid rgba(34,197,94,0.3); border-radius: 4px; padding: 3px 10px; cursor: pointer; font-size: 12px; } .btn-approve { background: var(--success-dim); color: var(--success-light); border: 1px solid rgba(34,197,94,0.3); border-radius: var(--radius-sm); padding: 3px 10px; cursor: pointer; font-size: 12px; }
.team-card { padding: 8px 10px; background: var(--glass); border-radius: 6px; margin-bottom: 6px; } .team-card { padding: 8px 10px; background: var(--glass); border-radius: var(--radius); margin-bottom: 6px; }
.team-card-info { display: flex; align-items: center; gap: 8px; } .team-card-info { display: flex; align-items: center; gap: 8px; }
.team-admin-back { .team-admin-back {
cursor: pointer; margin-right: 4px; opacity: 0.5; cursor: pointer; margin-right: 4px; opacity: 0.5;
@@ -297,9 +297,9 @@
.team-tab-content { padding: 0; } .team-tab-content { padding: 0; }
/* Health + Routing admin (v0.22.3) */ /* Health + Routing admin (v0.22.3) */
.admin-health-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; } .admin-health-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: var(--sp-3); }
.admin-health-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 8px; padding: 12px; } .admin-health-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--sp-3); }
.list-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 8px; } .list-card { background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--sp-3); margin-bottom: var(--sp-2); }
.admin-user-row.user-inactive { opacity: 0.7; } .admin-user-row.user-inactive { opacity: 0.7; }
@@ -324,8 +324,8 @@
} }
.cmd-input-wrap { .cmd-input-wrap {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: var(--sp-3);
padding: 12px 16px; border-bottom: 1px solid var(--border); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border);
} }
.cmd-input-wrap svg { color: var(--text-3); flex-shrink: 0; } .cmd-input-wrap svg { color: var(--text-3); flex-shrink: 0; }
.cmd-input-wrap input { .cmd-input-wrap input {
@@ -336,17 +336,17 @@
.cmd-kbd { .cmd-kbd {
font-size: 10px; font-family: var(--mono); color: var(--text-3); font-size: 10px; font-family: var(--mono); color: var(--text-3);
background: var(--bg); border: 1px solid var(--border); background: var(--bg); border: 1px solid var(--border);
border-radius: 4px; padding: 2px 6px; flex-shrink: 0; border-radius: var(--radius-sm); padding: 2px 6px; flex-shrink: 0;
} }
.cmd-results { .cmd-results {
max-height: 320px; overflow-y: auto; max-height: 320px; overflow-y: auto;
padding: 4px; padding: var(--sp-1);
} }
.cmd-item { .cmd-item {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: var(--sp-3);
padding: 8px 12px; border-radius: var(--radius); padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
cursor: pointer; font-size: 13px; color: var(--text-2); cursor: pointer; font-size: 13px; color: var(--text-2);
transition: background 60ms ease, color 60ms ease; transition: background 60ms ease, color 60ms ease;
} }
@@ -362,18 +362,18 @@
.cmd-item.active .cmd-item-hint { color: var(--accent); } .cmd-item.active .cmd-item-hint { color: var(--accent); }
.cmd-group-label { .cmd-group-label {
font-size: 10px; font-weight: 600; color: var(--text-3); font-size: 10px; font-weight: 600; color: var(--text-3);
padding: 8px 12px 4px; text-transform: uppercase; letter-spacing: 0.5px; padding: var(--sp-2) var(--sp-3) var(--sp-1); text-transform: uppercase; letter-spacing: 0.5px;
} }
.cmd-footer { .cmd-footer {
display: flex; align-items: center; gap: 16px; display: flex; align-items: center; gap: var(--sp-4);
padding: 8px 16px; border-top: 1px solid var(--border); padding: var(--sp-2) var(--sp-4); border-top: 1px solid var(--border);
font-size: 11px; color: var(--text-3); font-size: 11px; color: var(--text-3);
} }
.cmd-footer kbd { .cmd-footer kbd {
font-size: 10px; font-family: var(--mono); font-size: 10px; font-family: var(--mono);
background: var(--bg); border: 1px solid var(--border); background: var(--bg); border: 1px solid var(--border);
border-radius: 3px; padding: 1px 4px; border-radius: var(--radius-sm); padding: 1px 4px;
} }
@@ -393,15 +393,15 @@
animation: confirm-in 0.15s ease; animation: confirm-in 0.15s ease;
} }
.confirm-header { .confirm-header {
padding: 16px 20px 0; font-weight: 600; font-size: 15px; color: var(--text); padding: var(--sp-4) var(--sp-5) 0; font-weight: 600; font-size: 15px; color: var(--text);
} }
.confirm-body { .confirm-body {
padding: 12px 20px 20px; font-size: 13px; color: var(--text-2); padding: var(--sp-3) var(--sp-5) var(--sp-5); font-size: 13px; color: var(--text-2);
line-height: 1.5; white-space: pre-wrap; line-height: 1.5; white-space: pre-wrap;
} }
.confirm-footer { .confirm-footer {
display: flex; justify-content: flex-end; gap: 8px; display: flex; justify-content: flex-end; gap: var(--sp-2);
padding: 0 20px 16px; padding: 0 var(--sp-5) var(--sp-4);
} }
@keyframes confirm-in { from { opacity: 0; transform: scale(0.95); } } @keyframes confirm-in { from { opacity: 0; transform: scale(0.95); } }
@keyframes fade-in { from { opacity: 0; } } @keyframes fade-in { from { opacity: 0; } }
@@ -409,7 +409,7 @@
/* ── Group Chat persona picker (v0.23.0) ────── */ /* ── Group Chat persona picker (v0.23.0) ────── */
.group-persona-item { .group-persona-item {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 6px 10px; border-radius: var(--radius); padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
cursor: pointer; font-size: 13px; cursor: pointer; font-size: 13px;
transition: background 0.1s; transition: background 0.1s;
} }
@@ -429,21 +429,21 @@
.sb-dialog { .sb-dialog {
background: var(--bg-raised); border: 1px solid var(--border-light); background: var(--bg-raised); border: 1px solid var(--border-light);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
padding: 20px 22px 16px; padding: var(--sp-5) var(--sp-5) var(--sp-4);
min-width: 300px; max-width: 420px; width: 90vw; min-width: 300px; max-width: 420px; width: 90vw;
display: flex; flex-direction: column; gap: 12px; display: flex; flex-direction: column; gap: var(--sp-3);
box-shadow: 0 8px 32px rgba(0,0,0,0.5); box-shadow: 0 8px 32px rgba(0,0,0,0.5);
animation: confirm-in 0.15s ease; animation: confirm-in 0.15s ease;
} }
.sb-dialog-title { font-size: 14px; font-weight: 600; color: var(--text); } .sb-dialog-title { font-size: 14px; font-weight: 600; color: var(--text); }
.sb-dialog-input { .sb-dialog-input {
background: var(--bg-surface); border: 1px solid var(--border); background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 8px 10px; border-radius: var(--radius); padding: var(--sp-2) var(--sp-3);
font-size: 13px; color: var(--text); font-size: 13px; color: var(--text);
font-family: inherit; outline: none; width: 100%; box-sizing: border-box; font-family: inherit; outline: none; width: 100%; box-sizing: border-box;
} }
.sb-dialog-input:focus { border-color: var(--accent); } .sb-dialog-input:focus { border-color: var(--accent); }
.sb-dialog-color-row { display: flex; align-items: center; gap: 10px; } .sb-dialog-color-row { display: flex; align-items: center; gap: var(--sp-3); }
.sb-dialog-color-label { font-size: 12px; color: var(--text-2); } .sb-dialog-color-label { font-size: 12px; color: var(--text-2); }
.sb-dialog-color-input { width: 32px; height: 26px; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; padding: 1px; background: none; } .sb-dialog-color-input { width: 32px; height: 26px; border: 1px solid var(--border); border-radius: var(--radius); cursor: pointer; padding: 1px; background: none; }
.sb-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; } .sb-dialog-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); }

View File

@@ -1,27 +1,11 @@
/* ── primitives.css ────────────────────────── /* ── primitives.css ──────────────────────────
Buttons, forms, toasts, popups, toggles, badges Forms, toggles, badges, mode selector, REPL, SDK primitives.
Buttons/toasts/popup-menus migrated to sw-primitives.css (v0.6.11).
──────────────────────────────────────────── */ ──────────────────────────────────────────── */
/* ── Buttons ─────────────────────────────── */ /* ── Buttons ─────────────────────────────── */
button { font-family: var(--font); cursor: pointer; } button { font-family: var(--font); cursor: pointer; }
.btn-primary {
background: var(--accent); color: var(--text-on-color); border: none;
padding: 8px 16px; border-radius: var(--radius);
font-weight: 500; font-size: 13px;
transition: background var(--transition);
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-full { width: 100%; }
.btn-small {
background: var(--bg-raised); border: 1px solid var(--border);
color: var(--text-2); padding: 6px 12px; border-radius: var(--radius);
font-size: 12px; transition: all var(--transition);
}
.btn-small:hover { background: var(--bg-hover); color: var(--text); }
.btn-small.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--text-on-color); }
.btn-danger { background: var(--danger); color: var(--text-on-color); border: none; border-radius: var(--radius); }
/* ── Forms ────────────────────────────────── */ /* ── Forms ────────────────────────────────── */
@@ -31,7 +15,7 @@ button { font-family: var(--font); cursor: pointer; }
select, input[type="text"], input[type="email"], input[type="password"], select, input[type="text"], input[type="email"], input[type="password"],
input[type="number"], input[type="search"], textarea { input[type="number"], input[type="search"], textarea {
background: var(--bg-raised); border: 1px solid var(--border); background: var(--bg-raised); border: 1px solid var(--border);
color: var(--text); padding: 8px 12px; border-radius: var(--radius); color: var(--text); padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
font-family: var(--font); font-size: 13px; font-family: var(--font); font-size: 13px;
transition: border-color var(--transition); transition: border-color var(--transition);
} }
@@ -41,12 +25,12 @@ input[type="password"]:focus, textarea:focus {
} }
select option { background: var(--bg-surface); color: var(--text); } select option { background: var(--bg-surface); color: var(--text); }
.form-row { display: flex; gap: 0.75rem; } .form-row { display: flex; gap: var(--sp-3); }
.form-row .form-group { flex: 1; } .form-row .form-group { flex: 1; }
.form-hint { font-weight: 400; color: var(--text-3); margin-left: 4px; } .form-hint { font-weight: 400; color: var(--text-3); margin-left: 4px; }
.range-input { .range-input {
width: 100%; height: 4px; -webkit-appearance: none; appearance: none; width: 100%; height: 4px; -webkit-appearance: none; appearance: none;
background: var(--border); border-radius: 2px; outline: none; background: var(--border); border-radius: var(--radius-sm); outline: none;
margin: 8px 0 4px; cursor: pointer; margin: 8px 0 4px; cursor: pointer;
} }
.range-input::-webkit-slider-thumb { .range-input::-webkit-slider-thumb {
@@ -61,20 +45,7 @@ select option { background: var(--bg-surface); color: var(--text); }
.checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); } .checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 14px; cursor: pointer; margin: 0.5rem 0; color: var(--text-2); }
/* ── Toast ────────────────────────────────── */ /* Toast system — see sw-primitives.css (.sw-toast) */
.toast-container { position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%); z-index: 10000; display: flex; flex-direction: column; gap: 0.5rem; }
.toast {
background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 8px 16px; font-size: 13px;
display: flex; align-items: center; gap: 8px;
animation: toast-in 0.25s ease; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.toast.error { border-color: var(--danger); }
.toast.warning { border-color: var(--warning); }
.toast.success { border-color: var(--success); }
.toast button { background: none; border: none; color: var(--text-3); cursor: pointer; }
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } }
/* ── Mode Selector (v0.21.3) ───────────── */ /* ── Mode Selector (v0.21.3) ───────────── */
@@ -86,7 +57,7 @@ select option { background: var(--bg-surface); color: var(--text); }
} }
.mode-selector { .mode-selector {
display: flex; gap: 2px; padding: 6px 8px; display: flex; gap: 2px; padding: var(--sp-2) var(--sp-2);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -110,16 +81,16 @@ select option { background: var(--bg-surface); color: var(--text); }
/* ── REPL Console (v0.21.3) ────────────── */ /* ── REPL Console (v0.21.3) ────────────── */
.repl-toolbar { .repl-toolbar {
display: flex; gap: 0.5rem; padding: 6px 12px; display: flex; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border); font-size: 11px; border-bottom: 1px solid var(--border); font-size: 11px;
} }
.repl-output { .repl-output {
flex: 1; overflow-y: auto; padding: 6px; flex: 1; overflow-y: auto; padding: var(--sp-2);
font-family: var(--mono); font-size: 12px; line-height: 1.6; font-family: var(--mono); font-size: 12px; line-height: 1.6;
} }
.repl-input-wrap { .repl-input-wrap {
display: flex; align-items: flex-start; gap: 6px; display: flex; align-items: flex-start; gap: 6px;
padding: 8px 12px; border-top: 1px solid var(--border); padding: var(--sp-2) var(--sp-3); border-top: 1px solid var(--border);
background: var(--bg-raised); background: var(--bg-raised);
} }
.repl-prompt { .repl-prompt {
@@ -136,7 +107,7 @@ select option { background: var(--bg-surface); color: var(--text); }
.repl-entry { padding: 2px 6px; } .repl-entry { padding: 2px 6px; }
.repl-entry-input pre { color: var(--text-2); margin: 0; white-space: pre-wrap; word-break: break-all; } .repl-entry-input pre { color: var(--text-2); margin: 0; white-space: pre-wrap; word-break: break-all; }
.repl-entry-result { color: var(--text); } .repl-entry-result { color: var(--text); }
.repl-entry-error { color: var(--danger, #e55); } .repl-entry-error { color: var(--danger); }
.repl-entry-info { color: var(--text-3); font-size: 11px; } .repl-entry-info { color: var(--text-3); font-size: 11px; }
.repl-error-msg { margin: 0; white-space: pre-wrap; font-weight: 500; } .repl-error-msg { margin: 0; white-space: pre-wrap; font-weight: 500; }
@@ -170,29 +141,7 @@ select option { background: var(--bg-surface); color: var(--text); }
.repl-json-toggle:hover { color: var(--accent); } .repl-json-toggle:hover { color: var(--accent); }
/* ── Popup Menu (shared base) ───────────── */ /* Popup menu — see sw-primitives.css (.sw-menu) */
.popup-menu {
display: none; position: absolute; z-index: 200;
background: var(--bg-raised); border: 1px solid var(--border-light);
border-radius: var(--radius); padding: 4px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
min-width: 160px;
}
.popup-menu.open { display: block; }
.popup-menu-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px; border-radius: var(--radius);
background: none; border: none; color: var(--text-2);
cursor: pointer; font-size: 13px; width: 100%;
transition: background var(--transition), color var(--transition);
}
.popup-menu-item:hover { background: var(--bg-hover); color: var(--text); }
.popup-menu-item.disabled { opacity: 0.4; pointer-events: none; }
.popup-menu-danger:hover { color: var(--danger); }
.popup-menu-icon { flex-shrink: 0; display: flex; align-items: center; }
.popup-menu-hint { margin-left: auto; font-size: 11px; color: var(--text-3); }
.popup-menu-divider { height: 1px; background: var(--border); margin: 4px 8px; }
/* ── Tools Toggle ────────────────────────── */ /* ── Tools Toggle ────────────────────────── */
@@ -200,7 +149,7 @@ select option { background: var(--bg-surface); color: var(--text); }
.popup { .popup {
display: none; position: absolute; display: none; position: absolute;
background: var(--bg-raised); border: 1px solid var(--border-light); background: var(--bg-raised); border: 1px solid var(--border-light);
border-radius: var(--radius-lg); padding: 4px; border-radius: var(--radius-lg); padding: var(--sp-1);
box-shadow: var(--shadow-lg); z-index: 300; box-shadow: var(--shadow-lg); z-index: 300;
} }
.popup.open { display: block; } .popup.open { display: block; }
@@ -214,8 +163,8 @@ select option { background: var(--bg-surface); color: var(--text); }
} }
.tools-popup { bottom: 100%; left: 0; margin-bottom: 8px; min-width: 210px; } .tools-popup { bottom: 100%; left: 0; margin-bottom: 8px; min-width: 210px; }
.tools-popup-category, .tools-popup-tool { .tools-popup-category, .tools-popup-tool {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: var(--sp-3);
padding: 8px 12px; border-radius: var(--radius); padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
cursor: pointer; font-size: 13px; color: var(--text-2); cursor: pointer; font-size: 13px; color: var(--text-2);
transition: background var(--transition); transition: background var(--transition);
} }
@@ -266,17 +215,17 @@ select option { background: var(--bg-surface); color: var(--text); }
min-width: 240px; max-height: 300px; overflow-y: auto; min-width: 240px; max-height: 300px; overflow-y: auto;
} }
.kb-popup-header { .kb-popup-header {
padding: 8px 12px 4px; font-size: 12px; font-weight: 600; padding: var(--sp-2) var(--sp-3) var(--sp-1); font-size: 12px; font-weight: 600;
color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.5px;
} }
.kb-popup-item { .kb-popup-item {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: var(--sp-2);
padding: 6px 12px; cursor: pointer; padding: var(--sp-2) var(--sp-3); cursor: pointer;
transition: background var(--transition); transition: background var(--transition);
} }
.kb-popup-item:hover { background: var(--bg-hover); } .kb-popup-item:hover { background: var(--bg-hover); }
.kb-popup-item .kb-popup-check { .kb-popup-item .kb-popup-check {
width: 16px; height: 16px; border-radius: 3px; width: 16px; height: 16px; border-radius: var(--radius-sm);
border: 1.5px solid var(--border); flex-shrink: 0; border: 1.5px solid var(--border); flex-shrink: 0;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
transition: all var(--transition); transition: all var(--transition);
@@ -324,7 +273,7 @@ select option { background: var(--bg-surface); color: var(--text); }
/* ── Shared Form Controls ────────────────── */ /* ── Shared Form Controls ────────────────── */
.form-group { margin-bottom: 14px; } .form-group { margin-bottom: var(--sp-3); }
.form-group label { .form-group label {
display: block; font-size: 12px; font-weight: 500; display: block; font-size: 12px; font-weight: 500;
color: var(--text-2); margin-bottom: 5px; color: var(--text-2); margin-bottom: 5px;
@@ -342,11 +291,11 @@ select option { background: var(--bg-surface); color: var(--text); }
.toggle-group { .toggle-group {
display: flex; gap: 1px; padding: 3px; display: flex; gap: 1px; padding: 3px;
background: var(--bg); border-radius: 10px; background: var(--bg); border-radius: var(--radius-lg);
border: 1px solid var(--border); width: fit-content; border: 1px solid var(--border); width: fit-content;
} }
.toggle-btn { .toggle-btn {
padding: 6px 14px; border: none; border-radius: 7px; padding: 6px 14px; border: none; border-radius: var(--radius);
background: transparent; color: var(--text-3); background: transparent; color: var(--text-3);
cursor: pointer; font-size: 12px; font-weight: 500; cursor: pointer; font-size: 12px; font-weight: 500;
font-family: inherit; transition: all 0.15s; font-family: inherit; transition: all 0.15s;
@@ -354,15 +303,11 @@ select option { background: var(--bg-surface); color: var(--text); }
.toggle-btn.active { background: var(--bg-raised); color: var(--text); } .toggle-btn.active { background: var(--bg-raised); color: var(--text); }
.toggle-btn:hover { color: var(--text-2); } .toggle-btn:hover { color: var(--text-2); }
.btn-ghost { background: transparent; color: var(--text-2); border: 1px solid var(--border); } /* Button variants — see sw-primitives.css (.sw-btn--*) */
.btn-ghost:hover { background: var(--bg-hover); }
.btn-subtle { background: var(--accent-dim); color: var(--accent); border: none; }
.btn-sm { padding: 5px 11px; font-size: 12px; border-radius: 6px; cursor: pointer; font-weight: 500; font-family: inherit; }
.btn-md { padding: 7px 15px; font-size: 13px; border-radius: var(--radius); cursor: pointer; font-weight: 500; font-family: inherit; }
/* ── Badge ───────────────────────────────── */ /* ── Badge ───────────────────────────────── */
.badge { .badge {
font-size: 10px; padding: 2px 8px; border-radius: 4px; font-size: 10px; padding: 2px 8px; border-radius: var(--radius-sm);
font-weight: 600; letter-spacing: 0.3px; white-space: nowrap; font-weight: 600; letter-spacing: 0.3px; white-space: nowrap;
display: inline-flex; align-items: center; display: inline-flex; align-items: center;
} }
@@ -391,7 +336,7 @@ select option { background: var(--bg-surface); color: var(--text); }
/* ── Icon Button ─────────────────────────── */ /* ── Icon Button ─────────────────────────── */
.icon-btn { .icon-btn {
background: none; border: none; color: var(--text-3); background: none; border: none; color: var(--text-3);
cursor: pointer; padding: 5px; border-radius: 7px; cursor: pointer; padding: var(--sp-1); border-radius: var(--radius);
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
transition: all 0.15s; transition: all 0.15s;
} }
@@ -400,9 +345,9 @@ select option { background: var(--bg-surface); color: var(--text); }
.icon-btn-danger:hover { color: var(--danger); } .icon-btn-danger:hover { color: var(--danger); }
/* ── Utility States ─────────────────────── */ /* ── Utility States ─────────────────────── */
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; } .loading { color: var(--text-3); font-size: 13px; padding: var(--sp-2); }
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; } .error-hint { color: var(--danger); font-size: 13px; padding: var(--sp-2); }
.empty-hint { color: var(--text-3); font-size: 13px; text-align: center; padding: 1rem; } .empty-hint { color: var(--text-3); font-size: 13px; text-align: center; padding: var(--sp-4); }
/* ── SDK Primitives (sw.menu, sw.dropdown, sw.toolbar, sw.tabs) ── */ /* ── SDK Primitives (sw.menu, sw.dropdown, sw.toolbar, sw.tabs) ── */
@@ -410,8 +355,8 @@ select option { background: var(--bg-surface); color: var(--text); }
.sw-menu-wrap { position: relative; display: inline-flex; } .sw-menu-wrap { position: relative; display: inline-flex; }
.sw-menu-flyout { .sw-menu-flyout {
display: none; position: absolute; right: 0; left: auto; z-index: 200; display: none; position: absolute; right: 0; left: auto; z-index: 200;
background: var(--bg-elevated, #42424e); border: 1px solid var(--border-elevated, #555); background: var(--bg-elevated); border: 1px solid var(--border-elevated);
border-radius: var(--radius-lg, 8px); padding: 4px; min-width: 170px; border-radius: var(--radius-lg); padding: 4px; min-width: 170px;
box-shadow: 0 8px 40px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.08); box-shadow: 0 8px 40px rgba(0,0,0,0.8), 0 0 0 1px rgba(255,255,255,0.08);
} }
.sw-menu-flyout[data-position="down"] { top: 100%; margin-top: 4px; } .sw-menu-flyout[data-position="down"] { top: 100%; margin-top: 4px; }
@@ -421,63 +366,40 @@ select option { background: var(--bg-surface); color: var(--text); }
.sw-menu-flyout[data-fixed] { position: fixed; } .sw-menu-flyout[data-fixed] { position: fixed; }
/* Flyout items — single source of truth for all flyout menus (sw.menu + UserMenu) */ /* Flyout items — single source of truth for all flyout menus (sw.menu + UserMenu) */
.sw-menu-flyout .flyout-item { .sw-menu-flyout .flyout-item {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: var(--sp-3);
padding: 8px 12px; border-radius: var(--radius, 6px); padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
background: none; border: none; color: var(--text, #e8e8ed); background: none; border: none; color: var(--text);
cursor: pointer; font-size: 13px; font-family: inherit; width: 100%; cursor: pointer; font-size: 13px; font-family: inherit; width: 100%;
transition: background 0.15s, color 0.15s; transition: background 0.15s, color 0.15s;
} }
.sw-menu-flyout .flyout-item:hover { .sw-menu-flyout .flyout-item:hover {
background: var(--bg-hover, rgba(255,255,255,0.06)); color: var(--text, #eee); background: var(--bg-hover); color: var(--text);
} }
.sw-menu-flyout .flyout-item svg { flex-shrink: 0; opacity: 0.8; } .sw-menu-flyout .flyout-item svg { flex-shrink: 0; opacity: 0.8; }
.sw-menu-flyout .flyout-danger { color: var(--danger, #f44336); } .sw-menu-flyout .flyout-danger { color: var(--danger); }
.sw-menu-flyout .flyout-danger:hover { .sw-menu-flyout .flyout-danger:hover {
background: var(--danger-dim, rgba(244,67,54,0.1)); color: var(--danger, #f44336); background: var(--danger-dim); color: var(--danger);
} }
.sw-menu-flyout .flyout-divider { .sw-menu-flyout .flyout-divider {
border: none; height: 1px; background: var(--border, #2a2a2e); margin: 4px 0; border: none; height: 1px; background: var(--border); margin: var(--sp-1) 0;
} }
/* Dropdown — styled native select */ /* Dropdown — see sw-primitives.css (.sw-dropdown) */
.sw-dropdown {
background: var(--bg-raised, #222); color: var(--text, #eee);
border: 1px solid var(--border, #2e2e35); border-radius: var(--radius, 6px);
padding: 6px 10px; font-size: 13px; font-family: inherit;
cursor: pointer; min-width: 100px;
}
.sw-dropdown:hover { border-color: var(--border-light, #444); }
.sw-dropdown:focus { outline: none; border-color: var(--accent, #b38a4e); }
/* Toolbar — horizontal button row */ /* Toolbar — horizontal button row */
.sw-toolbar { .sw-toolbar {
display: flex; align-items: center; gap: 4px; display: flex; align-items: center; gap: var(--sp-1);
padding: 4px 0; padding: var(--sp-1) 0;
} }
/* Tabs — tabbed content container */ /* Tabs — see sw-primitives.css (.sw-tabs) */
.sw-tabs { display: flex; flex-direction: column; height: 100%; }
.sw-tabs-bar {
display: flex; gap: 0; border-bottom: 1px solid var(--border, #2e2e35);
flex-shrink: 0;
}
.sw-tab-btn {
padding: 8px 16px; background: none; border: none; border-bottom: 2px solid transparent;
color: var(--text-2, #999); cursor: pointer; font-size: 12px; font-weight: 600;
font-family: inherit; text-transform: uppercase; letter-spacing: 0.5px;
transition: color 0.15s, border-color 0.15s;
}
.sw-tab-btn:hover { color: var(--text, #eee); }
.sw-tab-btn--active { color: var(--accent, #b38a4e); border-bottom-color: var(--accent, #b38a4e); }
.sw-tabs-content { flex: 1; min-height: 0; overflow: hidden; }
.sw-tab-panel { height: 100%; overflow-y: auto; }
/* ── Responsive: Mobile ───────────────────── */ /* ── Responsive: Mobile ───────────────────── */
@media (max-width: 768px) { @media (max-width: 768px) {
/* Flyout items: larger touch targets */ /* Flyout items: larger touch targets */
.sw-menu-flyout .flyout-item { .sw-menu-flyout .flyout-item {
padding: 10px 12px; padding: var(--sp-3) var(--sp-3);
min-height: 44px; min-height: 44px;
} }
@@ -491,6 +413,7 @@ select option { background: var(--bg-surface); color: var(--text); }
width: calc(100vw - 32px); width: calc(100vw - 32px);
max-width: 100%; max-width: 100%;
max-height: calc(100vh - 64px); max-height: calc(100vh - 64px);
max-height: calc(100dvh - 64px);
} }
/* Toast: full width, centered */ /* Toast: full width, centered */
@@ -507,17 +430,17 @@ select option { background: var(--bg-surface); color: var(--text); }
.sw-user-picker__input { .sw-user-picker__input {
width: 100%; width: 100%;
padding: 8px 10px; padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius, 6px); border-radius: var(--radius);
border: 1px solid var(--border, #2a2a2e); border: 1px solid var(--border);
background: var(--bg, #0e0e10); background: var(--bg);
color: var(--text, #eee); color: var(--text);
font-size: 0.85em; font-size: 0.85em;
} }
.sw-user-picker__input:focus { .sw-user-picker__input:focus {
outline: none; outline: none;
border-color: var(--accent, #b38a4e); border-color: var(--accent);
} }
.sw-user-picker__spinner { .sw-user-picker__spinner {
@@ -527,8 +450,8 @@ select option { background: var(--bg-surface); color: var(--text); }
transform: translateY(-50%); transform: translateY(-50%);
width: 14px; width: 14px;
height: 14px; height: 14px;
border: 2px solid var(--border, #2a2a2e); border: 2px solid var(--border);
border-top-color: var(--accent, #b38a4e); border-top-color: var(--accent);
border-radius: 50%; border-radius: 50%;
animation: sw-spin 0.6s linear infinite; animation: sw-spin 0.6s linear infinite;
} }
@@ -541,9 +464,9 @@ select option { background: var(--bg-surface); color: var(--text); }
left: 0; left: 0;
right: 0; right: 0;
margin-top: 4px; margin-top: 4px;
background: var(--bg-elevated, #2a2a2e); background: var(--bg-elevated);
border: 1px solid var(--border, #2a2a2e); border: 1px solid var(--border);
border-radius: var(--radius, 6px); border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0,0,0,0.5); box-shadow: 0 8px 24px rgba(0,0,0,0.5);
z-index: 300; z-index: 300;
max-height: 240px; max-height: 240px;
@@ -553,22 +476,22 @@ select option { background: var(--bg-surface); color: var(--text); }
.sw-user-picker__option { .sw-user-picker__option {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: var(--sp-2);
padding: 8px 10px; padding: var(--sp-2) var(--sp-3);
cursor: pointer; cursor: pointer;
transition: background 0.1s; transition: background 0.1s;
} }
.sw-user-picker__option:hover, .sw-user-picker__option:hover,
.sw-user-picker__option--active { .sw-user-picker__option--active {
background: var(--bg-hover, rgba(255,255,255,0.06)); background: var(--bg-hover);
} }
.sw-user-picker__avatar { .sw-user-picker__avatar {
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 50%; border-radius: 50%;
background: var(--accent, #b38a4e); background: var(--accent);
color: #fff; color: #fff;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -586,11 +509,11 @@ select option { background: var(--bg-surface); color: var(--text); }
.sw-user-picker__name { .sw-user-picker__name {
font-size: 0.85em; font-size: 0.85em;
color: var(--text, #eee); color: var(--text);
} }
.sw-user-picker__handle { .sw-user-picker__handle {
font-size: 0.75em; font-size: 0.75em;
color: var(--text-muted, #888); color: var(--text-3);
} }

View File

@@ -6,13 +6,13 @@
.admin-storage-card { .admin-storage-card {
background: var(--bg-raised); border: 1px solid var(--border); background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px; margin-bottom: 12px; border-radius: var(--radius); padding: var(--sp-4); margin-bottom: var(--sp-3);
} }
.admin-storage-card h4 { margin: 0 0 10px 0; font-size: 14px; } .admin-storage-card h4 { margin: 0 0 var(--sp-3) 0; font-size: 14px; }
.admin-storage-desc { font-size: 12px; color: var(--text-3); margin: 0 0 10px 0; } .admin-storage-desc { font-size: 12px; color: var(--text-3); margin: 0 0 var(--sp-3) 0; }
.admin-storage-grid { .admin-storage-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px; gap: var(--sp-3);
} }
.admin-storage-item { display: flex; flex-direction: column; gap: 2px; } .admin-storage-item { display: flex; flex-direction: column; gap: 2px; }
.admin-storage-label { font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; } .admin-storage-label { font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; }
@@ -31,15 +31,15 @@
display: flex; height: 100%; overflow: hidden; display: flex; height: 100%; overflow: hidden;
} }
.settings-topbar { .settings-topbar {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: var(--sp-3);
padding: 10px 20px; border-bottom: 1px solid var(--border); padding: var(--sp-3) var(--sp-5); border-bottom: 1px solid var(--border);
background: var(--bg-surface); flex-shrink: 0; background: var(--bg-surface); flex-shrink: 0;
} }
.settings-topbar-back { .settings-topbar-back {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
background: none; border: none; color: var(--text-3); background: none; border: none; color: var(--text-3);
cursor: pointer; font-family: inherit; font-size: 13px; cursor: pointer; font-family: inherit; font-size: 13px;
padding: 4px 8px; border-radius: 6px; transition: color 0.12s; padding: 4px 8px; border-radius: var(--radius); transition: color 0.12s;
} }
.settings-topbar-back:hover { color: var(--text); } .settings-topbar-back:hover { color: var(--text); }
.settings-topbar-sep { width: 1px; height: 20px; background: var(--border); } .settings-topbar-sep { width: 1px; height: 20px; background: var(--border); }
@@ -48,12 +48,12 @@
width: 220px; flex-shrink: 0; width: 220px; flex-shrink: 0;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
background: var(--bg-surface); background: var(--bg-surface);
padding: 12px 8px; overflow-y: auto; padding: var(--sp-3) var(--sp-2); overflow-y: auto;
display: flex; flex-direction: column; gap: 2px; display: flex; flex-direction: column; gap: 2px;
} }
.settings-nav-link { .settings-nav-link {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: var(--sp-2);
padding: 8px 12px; border-radius: 8px; padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
color: var(--text-2); text-decoration: none; color: var(--text-2); text-decoration: none;
font-size: 13px; font-weight: 500; transition: all 0.12s; font-size: 13px; font-weight: 500; transition: all 0.12s;
border: none; background: none; cursor: pointer; border: none; background: none; cursor: pointer;
@@ -61,28 +61,28 @@
} }
.settings-nav-link:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; } .settings-nav-link:hover { background: var(--bg-hover); color: var(--text); text-decoration: none; }
.settings-nav-link.active { background: var(--accent-dim); color: var(--accent); } .settings-nav-link.active { background: var(--accent-dim); color: var(--accent); }
.settings-nav-sep { margin: 8px 0; border-top: 1px solid var(--border); } .settings-nav-sep { margin: var(--sp-2) 0; border-top: 1px solid var(--border); }
.settings-content { flex: 1; overflow-y: auto; padding: 28px; } .settings-content { flex: 1; overflow-y: auto; padding: var(--sp-8); }
.settings-content h2 { font-size: 18px; font-weight: 600; margin: 0 0 20px; } .settings-content h2 { font-size: 18px; font-weight: 600; margin: 0 0 var(--sp-5); }
.settings-content .settings-section { .settings-content .settings-section {
background: none; border: none; border-radius: 0; background: none; border: none; border-radius: 0;
padding: 0 0 16px; margin-bottom: 20px; max-width: 560px; padding: 0 0 var(--sp-4); margin-bottom: var(--sp-5); max-width: 560px;
} }
.settings-content .settings-section h3 { .settings-content .settings-section h3 {
font-size: 14px; font-weight: 600; margin: 0 0 14px; font-size: 14px; font-weight: 600; margin: 0 0 var(--sp-3);
padding-bottom: 8px; border-bottom: 1px solid var(--border); padding-bottom: var(--sp-2); border-bottom: 1px solid var(--border);
} }
.settings-section { .settings-section {
margin-bottom: 24px; padding: 20px; margin-bottom: var(--sp-6); padding: var(--sp-5);
background: var(--bg-surface); border-radius: 10px; background: var(--bg-surface); border-radius: var(--radius-lg);
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.settings-section h3 { .settings-section h3 {
font-size: 15px; font-weight: 600; margin: 0 0 16px; font-size: 15px; font-weight: 600; margin: 0 0 var(--sp-4);
padding-bottom: 10px; border-bottom: 1px solid var(--border); padding-bottom: var(--sp-3); border-bottom: 1px solid var(--border);
} }
.settings-placeholder { .settings-placeholder {
padding: 40px 20px; color: var(--text-3); padding: var(--sp-10) var(--sp-5); color: var(--text-3);
text-align: center; font-size: 13px; text-align: center; font-size: 13px;
} }
@@ -92,27 +92,27 @@
display: flex; flex-direction: column; height: 100%; overflow: hidden; display: flex; flex-direction: column; height: 100%; overflow: hidden;
} }
.admin-topbar { .admin-topbar {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: var(--sp-3);
padding: 10px 20px; border-bottom: 1px solid var(--border); padding: var(--sp-3) var(--sp-5); border-bottom: 1px solid var(--border);
background: var(--bg-surface); flex-shrink: 0; background: var(--bg-surface); flex-shrink: 0;
} }
.admin-topbar-back { .admin-topbar-back {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
background: none; border: none; color: var(--text-3); background: none; border: none; color: var(--text-3);
cursor: pointer; font-family: inherit; font-size: 13px; cursor: pointer; font-family: inherit; font-size: 13px;
padding: 4px 8px; border-radius: 6px; transition: color 0.12s; padding: 4px 8px; border-radius: var(--radius); transition: color 0.12s;
} }
.admin-topbar-back:hover { color: var(--text); } .admin-topbar-back:hover { color: var(--text); }
.admin-topbar-sep { width: 1px; height: 20px; background: var(--border); } .admin-topbar-sep { width: 1px; height: 20px; background: var(--border); }
.admin-topbar-title { font-size: 15px; font-weight: 600; } .admin-topbar-title { font-size: 15px; font-weight: 600; }
.admin-category-tabs { .admin-category-tabs {
display: flex; gap: 1px; padding: 3px; display: flex; gap: 1px; padding: 3px;
background: var(--bg); border-radius: 10px; background: var(--bg); border-radius: var(--radius-lg);
border: 1px solid var(--border); margin-left: auto; border: 1px solid var(--border); margin-left: auto;
} }
.admin-cat-btn { .admin-cat-btn {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: 7px; border: none; padding: 6px 14px; border-radius: var(--radius); border: none;
cursor: pointer; font-size: 13px; font-weight: 500; cursor: pointer; font-size: 13px; font-weight: 500;
font-family: inherit; background: transparent; color: var(--text-3); font-family: inherit; background: transparent; color: var(--text-3);
transition: all 0.12s; transition: all 0.12s;
@@ -123,10 +123,10 @@
.admin-nav { .admin-nav {
width: 200px; background: var(--bg-surface); width: 200px; background: var(--bg-surface);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
padding: 12px 8px; display: flex; flex-direction: column; gap: 2px; flex-shrink: 0; padding: var(--sp-3) var(--sp-2); display: flex; flex-direction: column; gap: 2px; flex-shrink: 0;
} }
.admin-nav-link { .admin-nav-link {
display: flex; padding: 8px 12px; border-radius: 8px; display: flex; padding: var(--sp-2) var(--sp-3); border-radius: var(--radius);
border: none; cursor: pointer; font-family: inherit; border: none; cursor: pointer; font-family: inherit;
font-size: 13px; font-weight: 500; width: 100%; font-size: 13px; font-weight: 500; width: 100%;
text-align: left; transition: all 0.12s; text-align: left; transition: all 0.12s;
@@ -137,30 +137,30 @@
.admin-nav-link.active { background: var(--accent-dim); color: var(--accent); } .admin-nav-link.active { background: var(--accent-dim); color: var(--accent); }
.admin-content { flex: 1; display: flex; flex-direction: column; min-width: 0; } .admin-content { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.admin-content-header { .admin-content-header {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: var(--sp-3);
padding: 14px 24px; border-bottom: 1px solid var(--border); flex-shrink: 0; padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border); flex-shrink: 0;
} }
.admin-content-header h2 { font-size: 16px; font-weight: 600; margin: 0; } .admin-content-header h2 { font-size: 16px; font-weight: 600; margin: 0; }
.admin-search { .admin-search {
display: flex; align-items: center; gap: 7px; display: flex; align-items: center; gap: 7px;
background: var(--input-bg, var(--bg)); border-radius: 8px; background: var(--input-bg, var(--bg)); border-radius: var(--radius);
padding: 6px 10px; border: 1px solid var(--border); width: 220px; padding: var(--sp-2) var(--sp-3); border: 1px solid var(--border); width: 220px;
} }
.admin-search input { .admin-search input {
background: none; border: none; color: var(--text); background: none; border: none; color: var(--text);
font-size: 12px; font-family: inherit; flex: 1; outline: none; font-size: 12px; font-family: inherit; flex: 1; outline: none;
} }
.admin-content-body { flex: 1; overflow-y: auto; padding: 24px; } .admin-content-body { flex: 1; overflow-y: auto; padding: var(--sp-6); }
/* ── Data Tables ─────────────────────────── */ /* ── Data Tables ─────────────────────────── */
.data-table { width: 100%; border-collapse: collapse; } .data-table { width: 100%; border-collapse: collapse; }
.data-table th { .data-table th {
padding: 10px 14px; text-align: left; font-weight: 600; padding: var(--sp-3) var(--sp-3); text-align: left; font-weight: 600;
font-size: 11px; color: var(--text-3); text-transform: uppercase; font-size: 11px; color: var(--text-3); text-transform: uppercase;
letter-spacing: 0.5px; border-bottom: 1px solid var(--border); letter-spacing: 0.5px; border-bottom: 1px solid var(--border);
} }
.data-table td { .data-table td {
padding: 10px 14px; font-size: 13px; padding: var(--sp-3) var(--sp-3); font-size: 13px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.data-table tr:hover td { background: var(--bg-hover); } .data-table tr:hover td { background: var(--bg-hover); }
@@ -171,15 +171,15 @@
display: flex; flex-direction: column; height: 100%; overflow: hidden; display: flex; flex-direction: column; height: 100%; overflow: hidden;
} }
.editor-topbar { .editor-topbar {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: var(--sp-2);
padding: 6px 12px; border-bottom: 1px solid var(--border); padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border);
background: var(--bg-surface); flex-shrink: 0; background: var(--bg-surface); flex-shrink: 0;
} }
.editor-topbar-back { .editor-topbar-back {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
background: none; border: none; color: var(--text-3); background: none; border: none; color: var(--text-3);
cursor: pointer; font-family: inherit; font-size: 13px; cursor: pointer; font-family: inherit; font-size: 13px;
padding: 4px 8px; border-radius: 6px; padding: 4px 8px; border-radius: var(--radius);
} }
.editor-topbar-back:hover { color: var(--text); } .editor-topbar-back:hover { color: var(--text); }
.editor-body { display: flex; flex: 1; min-height: 0; } .editor-body { display: flex; flex: 1; min-height: 0; }
@@ -189,10 +189,10 @@
display: flex; flex-direction: column; flex-shrink: 0; display: flex; flex-direction: column; flex-shrink: 0;
} }
.editor-tree-header { .editor-tree-header {
padding: 8px 10px; border-bottom: 1px solid var(--border); padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
} }
.editor-tree-files { flex: 1; overflow-y: auto; padding: 4px 0; } .editor-tree-files { flex: 1; overflow-y: auto; padding: var(--sp-1) 0; }
.editor-main { flex: 1; display: flex; flex-direction: column; min-width: 0; } .editor-main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.editor-tabs { .editor-tabs {
display: flex; align-items: center; display: flex; align-items: center;
@@ -201,7 +201,7 @@
} }
.editor-tab { .editor-tab {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
padding: 6px 12px; cursor: pointer; font-size: 12px; padding: var(--sp-2) var(--sp-3); cursor: pointer; font-size: 12px;
white-space: nowrap; border-right: 1px solid var(--border); white-space: nowrap; border-right: 1px solid var(--border);
background: transparent; color: var(--text-3); background: transparent; color: var(--text-3);
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
@@ -214,27 +214,27 @@
} }
.editor-content { flex: 1; overflow: auto; } .editor-content { flex: 1; overflow: auto; }
.editor-statusbar { .editor-statusbar {
display: flex; align-items: center; gap: 14px; display: flex; align-items: center; gap: var(--sp-3);
padding: 3px 12px; background: var(--bg-surface); padding: 3px var(--sp-3); background: var(--bg-surface);
border-top: 1px solid var(--border); flex-shrink: 0; border-top: 1px solid var(--border); flex-shrink: 0;
font-size: 11px; color: var(--text-3); font-size: 11px; color: var(--text-3);
font-family: var(--mono); font-family: var(--mono);
} }
/* ── Stat Cards Grid ──────────────────────── */ /* ── Stat Cards Grid ──────────────────────── */
.stat-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; } .stat-cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: var(--sp-3); margin-bottom: var(--sp-6); }
.stat-card { .stat-card {
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: var(--sp-4);
} }
.stat-card-label { font-size: 11px; color: var(--text-3); font-weight: 500; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.04em; } .stat-card-label { font-size: 11px; color: var(--text-3); font-weight: 500; margin-bottom: var(--sp-2); text-transform: uppercase; letter-spacing: 0.04em; }
.stat-card-value { font-size: 24px; font-weight: 700; line-height: 1; } .stat-card-value { font-size: 24px; font-weight: 700; line-height: 1; }
.stat-card-sub { font-size: 11px; color: var(--text-3); margin-top: 4px; } .stat-card-sub { font-size: 11px; color: var(--text-3); margin-top: 4px; }
/* ── Bar Chart ───────────────────────────── */ /* ── Bar Chart ───────────────────────────── */
.bar-chart { display: flex; align-items: flex-end; gap: 8px; height: 140px; } .bar-chart { display: flex; align-items: flex-end; gap: var(--sp-2); height: 140px; }
.bar-chart-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; } .bar-chart-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.bar-chart-val { font-size: 10px; color: var(--text-3); font-family: var(--mono); } .bar-chart-val { font-size: 10px; color: var(--text-3); font-family: var(--mono); }
.bar-chart-track { width: 100%; background: var(--accent-dim); border-radius: 4px; position: relative; height: 100px; } .bar-chart-track { width: 100%; background: var(--accent-dim); border-radius: var(--radius-sm); position: relative; height: 100px; }
.bar-chart-fill { position: absolute; bottom: 0; left: 0; right: 0; background: var(--accent); border-radius: 4px; transition: height 0.3s ease; } .bar-chart-fill { position: absolute; bottom: 0; left: 0; right: 0; background: var(--accent); border-radius: var(--radius-sm); transition: height 0.3s ease; }
.bar-chart-label { font-size: 10px; color: var(--text-3); } .bar-chart-label { font-size: 10px; color: var(--text-3); }
/* ── Admin Settings Form (flat sections, prototype match) ── */ /* ── Admin Settings Form (flat sections, prototype match) ── */
@@ -250,11 +250,11 @@
.admin-settings-form .toggle-label { margin-bottom: 8px; } .admin-settings-form .toggle-label { margin-bottom: 8px; }
/* ── Toggle Switch ────────────────────────── */ /* ── Toggle Switch ────────────────────────── */
.toggle-label { display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px; color: var(--text-1); } .toggle-label { display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 13px; color: var(--text); }
.toggle-label input[type="checkbox"] { display: none; } .toggle-label input[type="checkbox"] { display: none; }
.toggle-track { .toggle-track {
position: relative; width: 36px; height: 20px; flex-shrink: 0; position: relative; width: 36px; height: 20px; flex-shrink: 0;
background: var(--text-3); border-radius: 10px; transition: background 0.2s; background: var(--text-3); border-radius: var(--radius-lg); transition: background 0.2s;
} }
.toggle-track::after { .toggle-track::after {
content: ''; position: absolute; top: 2px; left: 2px; content: ''; position: absolute; top: 2px; left: 2px;
@@ -291,22 +291,28 @@
.admin-actions-cell { white-space: nowrap; text-align: right; } .admin-actions-cell { white-space: nowrap; text-align: right; }
/* ── Provider Cards ──────────────────────── */ /* ── Provider Cards ──────────────────────── */
.provider-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; } .provider-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--sp-4); }
.provider-card { .provider-card {
background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px; cursor: pointer; transition: border-color var(--transition), box-shadow var(--transition); padding: var(--sp-4); cursor: pointer; transition: border-color var(--transition), box-shadow var(--transition);
} }
.provider-card:hover { border-color: var(--accent); box-shadow: 0 2px 8px rgba(0,0,0,0.15); } .provider-card:hover { border-color: var(--accent); box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
.provider-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; } .provider-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--sp-3); }
.provider-card-name { font-weight: 600; font-size: 14px; } .provider-card-name { font-weight: 600; font-size: 14px; }
.provider-card-meta { display: flex; gap: 6px; align-items: center; margin-top: 4px; flex-wrap: wrap; } .provider-card-meta { display: flex; gap: 6px; align-items: center; margin-top: 4px; flex-wrap: wrap; }
@media (max-width: 768px) { @media (max-width: 1024px) {
.settings-nav { width: 160px; font-size: 12px; padding: 12px 8px; } .settings-nav { width: 180px; }
.settings-content { padding: 16px; }
.admin-nav { width: 160px; } .admin-nav { width: 160px; }
.admin-content-body { padding: 16px; } .editor-tree { width: 180px; }
}
@media (max-width: 768px) {
.settings-nav { width: 160px; font-size: 12px; padding: var(--sp-3) var(--sp-2); }
.settings-content { padding: var(--sp-4); }
.admin-nav { width: 160px; }
.admin-content-body { padding: var(--sp-4); }
.editor-tree { width: 180px; } .editor-tree { width: 180px; }
} }
@@ -322,7 +328,7 @@
} }
.sidebar-nav::-webkit-scrollbar { width: 4px; } .sidebar-nav::-webkit-scrollbar { width: 4px; }
.sidebar-nav::-webkit-scrollbar-track { background: transparent; } .sidebar-nav::-webkit-scrollbar-track { background: transparent; }
.sidebar-nav::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } .sidebar-nav::-webkit-scrollbar-thumb { background: var(--border); border-radius: var(--radius-sm); }
/* Section wrapper */ /* Section wrapper */
.sb-section { margin-bottom: 2px; } .sb-section { margin-bottom: 2px; }
@@ -364,7 +370,7 @@
color: var(--text-3); color: var(--text-3);
cursor: pointer; cursor: pointer;
padding: 2px; padding: 2px;
border-radius: 4px; border-radius: var(--radius-sm);
display: flex; display: flex;
align-items: center; align-items: center;
transition: opacity 0.1s, color 0.1s; transition: opacity 0.1s, color 0.1s;
@@ -394,7 +400,7 @@
gap: 5px; gap: 5px;
padding: 5px 10px; padding: 5px 10px;
cursor: pointer; cursor: pointer;
border-radius: 6px; border-radius: var(--radius);
margin: 0 4px; margin: 0 4px;
transition: background 0.1s; transition: background 0.1s;
} }
@@ -446,7 +452,7 @@
font-size: 14px; font-size: 14px;
padding: 0 2px; padding: 0 2px;
line-height: 1; line-height: 1;
border-radius: 4px; border-radius: var(--radius-sm);
transition: opacity 0.1s; transition: opacity 0.1s;
} }
.sb-proj-header:hover .sb-proj-menu { opacity: 1; } .sb-proj-header:hover .sb-proj-menu { opacity: 1; }
@@ -480,7 +486,7 @@
align-items: center; align-items: center;
gap: 7px; gap: 7px;
padding: 5px 12px; padding: 5px 12px;
border-radius: 6px; border-radius: var(--radius);
margin: 0 4px; margin: 0 4px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
@@ -494,7 +500,7 @@
.sb-ch-menu { .sb-ch-menu {
visibility: hidden; background: none; border: none; color: var(--text-3); cursor: pointer; visibility: hidden; background: none; border: none; color: var(--text-3); cursor: pointer;
font-size: 14px; padding: 0 4px; line-height: 1; border-radius: 3px; margin-left: auto; font-size: 14px; padding: 0 4px; line-height: 1; border-radius: var(--radius-sm); margin-left: auto;
} }
.sb-ch-menu:hover { color: var(--text); background: var(--bg-hover); } .sb-ch-menu:hover { color: var(--text); background: var(--bg-hover); }
.sb-channel-item:hover .sb-ch-menu { visibility: visible; } .sb-channel-item:hover .sb-ch-menu { visibility: visible; }
@@ -509,14 +515,14 @@
background: var(--text-3); background: var(--text-3);
flex-shrink: 0; flex-shrink: 0;
} }
.sb-presence.online { background: var(--success-light, #22c55e); } .sb-presence.online { background: var(--success-light); }
.sb-unread { .sb-unread {
font-size: 10px; font-size: 10px;
font-weight: 700; font-weight: 700;
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
border-radius: 10px; border-radius: var(--radius-lg);
padding: 1px 5px; padding: 1px 5px;
min-width: 16px; min-width: 16px;
text-align: center; text-align: center;

View File

@@ -31,7 +31,7 @@
.debug-check { display: flex; align-items: center; gap: 4px; color: var(--text-3); cursor: pointer; } .debug-check { display: flex; align-items: center; gap: 4px; color: var(--text-3); cursor: pointer; }
.debug-badge { .debug-badge {
font-size: 10px; background: var(--bg-raised); border: 1px solid var(--border); font-size: 10px; background: var(--bg-raised); border: 1px solid var(--border);
border-radius: 10px; padding: 1px 6px; color: var(--text-3); margin-left: auto; border-radius: var(--radius-lg); padding: 1px 6px; color: var(--text-3); margin-left: auto;
} }
.debug-content { .debug-content {
flex: 1; overflow-y: auto; padding: 6px; flex: 1; overflow-y: auto; padding: 6px;
@@ -42,7 +42,7 @@
.debug-pre { .debug-pre {
white-space: pre-wrap; word-break: break-all; white-space: pre-wrap; word-break: break-all;
max-height: 200px; overflow: auto; padding: 6px; max-height: 200px; overflow: auto; padding: 6px;
background: rgba(0,0,0,0.2); border-radius: 4px; margin: 4px 0; background: rgba(0,0,0,0.2); border-radius: var(--radius-sm); margin: 4px 0;
} }
/* ── Console Tab ─────────────────────────── */ /* ── Console Tab ─────────────────────────── */

View File

@@ -4,7 +4,7 @@
──────────────────────────────────────────── */ ──────────────────────────────────────────── */
/* ── Layout ──────────────────────────────────── */ /* ── Layout ──────────────────────────────────── */
.login-shell { display: flex; flex-direction: column; height: 100vh; } .login-shell { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
.login-main { display: flex; flex: 1; min-height: 0; } .login-main { display: flex; flex: 1; min-height: 0; }
/* ── Hero Panel (left) ─────────────────────── */ /* ── Hero Panel (left) ─────────────────────── */
@@ -66,7 +66,7 @@
/* ── Auth Panel (right) ────────────────────── */ /* ── Auth Panel (right) ────────────────────── */
.login-auth-panel { .login-auth-panel {
width: 440px; flex-shrink: 0; display: flex; align-items: center; width: 440px; flex-shrink: 0; display: flex; align-items: center;
justify-content: center; padding: 2rem; background: var(--bg-surface); justify-content: center; padding: var(--sp-8); background: var(--bg-surface);
border-left: 1px solid var(--border); overflow-y: auto; border-left: 1px solid var(--border); overflow-y: auto;
} }
.login-auth-inner { width: 100%; max-width: 340px; } .login-auth-inner { width: 100%; max-width: 340px; }
@@ -84,7 +84,7 @@
.login-auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); } .login-auth-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.login-auth-tab:hover { color: var(--text-2); } .login-auth-tab:hover { color: var(--text-2); }
.login-form-field { margin-bottom: 16px; } .login-form-field { margin-bottom: var(--sp-4); }
.login-form-field label { .login-form-field label {
display: block; font-size: 13px; font-weight: 500; display: block; font-size: 13px; font-weight: 500;
color: var(--text-2); margin-bottom: 6px; color: var(--text-2); margin-bottom: 6px;
@@ -92,13 +92,20 @@
.login-form-field input { .login-form-field input {
width: 100%; box-sizing: border-box; background: var(--input-bg); width: 100%; box-sizing: border-box; background: var(--input-bg);
border: 1px solid var(--border); color: var(--text); border: 1px solid var(--border); color: var(--text);
padding: 10px 14px; border-radius: 10px; font-size: 14px; font-family: var(--font); padding: var(--sp-3) var(--sp-3); border-radius: var(--radius-lg); font-size: 14px; font-family: var(--font);
outline: none; transition: border-color 0.15s, box-shadow 0.15s; outline: none; transition: border-color 0.15s, box-shadow 0.15s;
} }
.login-form-field input:focus { .login-form-field input:focus {
border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim);
} }
.login-keep-login {
display: flex; align-items: center; gap: 6px; margin-top: 4px;
font-size: 0.82rem; color: var(--text-2); cursor: pointer;
user-select: none;
}
.login-keep-login input[type="checkbox"] { margin: 0; cursor: pointer; }
.login-auth-error { .login-auth-error {
color: var(--danger); font-size: 0.78rem; margin: 0 0 0.5rem; color: var(--danger); font-size: 0.78rem; margin: 0 0 0.5rem;
} }
@@ -106,7 +113,7 @@
.login-btn { .login-btn {
width: 100%; background: var(--accent); color: #fff; border: none; width: 100%; background: var(--accent); color: #fff; border: none;
padding: 10px 18px; font-size: 14px; border-radius: 10px; cursor: pointer; padding: var(--sp-3) var(--sp-5); font-size: 14px; border-radius: var(--radius-lg); cursor: pointer;
font-family: var(--font); font-weight: 600; letter-spacing: -0.01em; font-family: var(--font); font-weight: 600; letter-spacing: -0.01em;
transition: all 0.18s; margin-top: 1.5rem; transition: all 0.18s; margin-top: 1.5rem;
} }
@@ -151,7 +158,7 @@
.login-anim-a3 { animation-delay: .29s; } .login-anim-a3 { animation-delay: .29s; }
/* ── Responsive ─────────────────────────────── */ /* ── Responsive ─────────────────────────────── */
@media (max-width: 900px) { @media (max-width: 1024px) {
.login-hero { display: none; } .login-hero { display: none; }
.login-auth-panel { width: 100%; border-left: none; } .login-auth-panel { width: 100%; border-left: none; }
} }

View File

@@ -13,9 +13,9 @@
white-space: nowrap; position: relative; white-space: nowrap; position: relative;
} }
.sw-btn:disabled { opacity: 0.5; cursor: not-allowed; } .sw-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.sw-btn--sm { padding: 0.3rem 0.65rem; font-size: 0.8rem; } .sw-btn--sm { padding: var(--sp-1h) var(--sp-2h); font-size: 0.8rem; }
.sw-btn--md { padding: 0.45rem 0.9rem; font-size: 0.875rem; } .sw-btn--md { padding: var(--sp-2) var(--sp-3); font-size: 0.875rem; }
.sw-btn--lg { padding: 0.6rem 1.2rem; font-size: 1rem; } .sw-btn--lg { padding: var(--sp-2) var(--sp-4); font-size: 1rem; }
.sw-btn--primary { background: var(--accent); color: var(--text-on-color); } .sw-btn--primary { background: var(--accent); color: var(--text-on-color); }
.sw-btn--primary:hover:not(:disabled) { background: var(--accent-hover); } .sw-btn--primary:hover:not(:disabled) { background: var(--accent-hover); }
@@ -23,6 +23,8 @@
.sw-btn--secondary:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-light); } .sw-btn--secondary:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--border-light); }
.sw-btn--danger { background: var(--danger); color: var(--text-on-color); } .sw-btn--danger { background: var(--danger); color: var(--text-on-color); }
.sw-btn--danger:hover:not(:disabled) { opacity: 0.9; } .sw-btn--danger:hover:not(:disabled) { opacity: 0.9; }
.sw-btn--success { background: var(--success); color: var(--text-on-color); }
.sw-btn--success:hover:not(:disabled) { opacity: 0.9; }
.sw-btn--ghost { background: transparent; color: var(--text-2); } .sw-btn--ghost { background: transparent; color: var(--text-2); }
.sw-btn--ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); } .sw-btn--ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
@@ -60,7 +62,7 @@
/* ── FormField ─────────────────────────────── */ /* ── FormField ─────────────────────────────── */
.sw-field { display: flex; flex-direction: column; gap: 0.3rem; } .sw-field { display: flex; flex-direction: column; gap: var(--sp-1); }
.sw-field__label { font-size: 0.8rem; font-weight: 500; color: var(--text-2); } .sw-field__label { font-size: 0.8rem; font-weight: 500; color: var(--text-2); }
.sw-field__required { color: var(--danger); margin-left: 2px; } .sw-field__required { color: var(--danger); margin-left: 2px; }
.sw-field__error { font-size: 0.75rem; color: var(--danger); } .sw-field__error { font-size: 0.75rem; color: var(--danger); }
@@ -70,7 +72,7 @@
/* ── Shared input ──────────────────────────── */ /* ── Shared input ──────────────────────────── */
.sw-input { .sw-input {
width: 100%; padding: 0.45rem 0.7rem; background: var(--input-bg); width: 100%; padding: var(--sp-2) var(--sp-2h); background: var(--input-bg);
color: var(--text); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius);
font-family: var(--font); font-size: 0.875rem; outline: none; font-family: var(--font); font-size: 0.875rem; outline: none;
transition: border-color var(--transition); transition: border-color var(--transition);
@@ -82,7 +84,7 @@
.sw-tooltip-wrap { position: relative; display: inline-flex; } .sw-tooltip-wrap { position: relative; display: inline-flex; }
.sw-tooltip { .sw-tooltip {
position: absolute; z-index: 1000; padding: 0.3rem 0.6rem; position: absolute; z-index: 1000; padding: var(--sp-1) var(--sp-2);
background: var(--bg-elevated); color: var(--text); border: 1px solid var(--border); background: var(--bg-elevated); color: var(--text); border: 1px solid var(--border);
border-radius: var(--radius); font-size: 0.75rem; white-space: nowrap; border-radius: var(--radius); font-size: 0.75rem; white-space: nowrap;
pointer-events: none; animation: sw-fade-in 0.15s ease; pointer-events: none; animation: sw-fade-in 0.15s ease;
@@ -98,7 +100,7 @@
.sw-banner { .sw-banner {
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
gap: 0.5rem; padding: 0.5rem 1rem; font-size: 0.85rem; font-weight: 500; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); font-size: 0.85rem; font-weight: 500;
width: 100%; flex-shrink: 0; width: 100%; flex-shrink: 0;
} }
.sw-banner--info { background: var(--accent-dim); color: var(--accent-light); } .sw-banner--info { background: var(--accent-dim); color: var(--accent-light); }
@@ -114,13 +116,13 @@
/* ── Toast ─────────────────────────────────── */ /* ── Toast ─────────────────────────────────── */
.sw-toast-container { .sw-toast-container {
position: fixed; bottom: 1rem; right: 1rem; z-index: 10000; position: fixed; bottom: var(--sp-4); right: var(--sp-4); z-index: 10000;
display: flex; flex-direction: column-reverse; gap: 0.5rem; display: flex; flex-direction: column-reverse; gap: var(--sp-2);
max-width: 380px; pointer-events: none; max-width: 380px; pointer-events: none;
} }
.sw-toast { .sw-toast {
display: flex; align-items: center; gap: 0.75rem; display: flex; align-items: center; gap: var(--sp-3);
padding: 0.75rem 1rem; border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-4); border-radius: var(--radius-lg);
background: var(--bg-raised); border: 1px solid var(--border); background: var(--bg-raised); border: 1px solid var(--border);
box-shadow: var(--shadow-lg); pointer-events: auto; box-shadow: var(--shadow-lg); pointer-events: auto;
font-size: 0.875rem; font-size: 0.875rem;
@@ -156,7 +158,7 @@
} }
.sw-dialog__header { .sw-dialog__header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.25rem 0.75rem; border-bottom: 1px solid var(--border); padding: var(--sp-4) var(--sp-5) var(--sp-3); border-bottom: 1px solid var(--border);
} }
.sw-dialog__title { font-size: 1rem; font-weight: 600; color: var(--text); margin: 0; } .sw-dialog__title { font-size: 1rem; font-weight: 600; color: var(--text); margin: 0; }
.sw-dialog__close { .sw-dialog__close {
@@ -164,10 +166,10 @@
font-size: 1.3rem; padding: 0; line-height: 1; font-size: 1.3rem; padding: 0; line-height: 1;
} }
.sw-dialog__close:hover { color: var(--text); } .sw-dialog__close:hover { color: var(--text); }
.sw-dialog__body { padding: 1rem 1.25rem; overflow-y: auto; color: var(--text); font-size: 0.9rem; line-height: 1.5; } .sw-dialog__body { padding: var(--sp-4) var(--sp-5); overflow-y: auto; color: var(--text); font-size: 0.9rem; line-height: 1.5; }
.sw-dialog__actions { .sw-dialog__actions {
display: flex; gap: 0.5rem; justify-content: flex-end; display: flex; gap: var(--sp-2); justify-content: flex-end;
padding: 0.75rem 1.25rem; border-top: 1px solid var(--border); padding: var(--sp-3) var(--sp-5); border-top: 1px solid var(--border);
} }
@keyframes sw-scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } } @keyframes sw-scale-in { from { transform: scale(0.95); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@@ -178,11 +180,11 @@
z-index: 6000; min-width: 160px; max-width: 300px; z-index: 6000; min-width: 160px; max-width: 300px;
background: var(--bg-surface); border: 1px solid var(--border); background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-lg); border-radius: var(--radius); box-shadow: var(--shadow-lg);
padding: 4px 0; outline: none; animation: sw-fade-in 0.1s ease; padding: var(--sp-1) 0; outline: none; animation: sw-fade-in 0.1s ease;
} }
.sw-menu__item { .sw-menu__item {
display: flex; align-items: center; gap: 0.5rem; display: flex; align-items: center; gap: var(--sp-2);
padding: 0.4rem 0.75rem; cursor: pointer; font-size: 0.85rem; padding: var(--sp-1h) var(--sp-3); cursor: pointer; font-size: 0.85rem;
color: var(--text); transition: background var(--transition); color: var(--text); transition: background var(--transition);
} }
.sw-menu__item:hover, .sw-menu__item--focused { background: var(--bg-hover); } .sw-menu__item:hover, .sw-menu__item--focused { background: var(--bg-hover); }
@@ -191,11 +193,11 @@
.sw-menu__icon { flex-shrink: 0; width: 1.1em; text-align: center; color: var(--text-2); } .sw-menu__icon { flex-shrink: 0; width: 1.1em; text-align: center; color: var(--text-2); }
.sw-menu__label { flex: 1; } .sw-menu__label { flex: 1; }
.sw-menu__arrow { font-size: 0.6em; color: var(--text-3); } .sw-menu__arrow { font-size: 0.6em; color: var(--text-3); }
.sw-menu__divider { height: 1px; background: var(--border); margin: 4px 0; } .sw-menu__divider { height: 1px; background: var(--border); margin: var(--sp-1) 0; }
@media (max-width: 768px) { @media (max-width: 768px) {
.sw-menu { max-width: calc(100vw - 16px); } .sw-menu { max-width: calc(100vw - 16px); }
.sw-menu__item { min-height: 44px; padding: 0.5rem 0.75rem; } .sw-menu__item { min-height: 44px; padding: var(--sp-2h) var(--sp-3); }
} }
/* ── Drawer ────────────────────────────────── */ /* ── Drawer ────────────────────────────────── */
@@ -219,7 +221,7 @@
.sw-drawer--left.sw-drawer--open { transform: translateX(0); } .sw-drawer--left.sw-drawer--open { transform: translateX(0); }
.sw-drawer__header { .sw-drawer__header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); flex-shrink: 0; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border); flex-shrink: 0;
} }
.sw-drawer__title { font-size: 1rem; font-weight: 600; color: var(--text); margin: 0; } .sw-drawer__title { font-size: 1rem; font-weight: 600; color: var(--text); margin: 0; }
.sw-drawer__close { .sw-drawer__close {
@@ -227,7 +229,7 @@
font-size: 1.3rem; padding: 0; line-height: 1; font-size: 1.3rem; padding: 0; line-height: 1;
} }
.sw-drawer__close:hover { color: var(--text); } .sw-drawer__close:hover { color: var(--text); }
.sw-drawer__body { flex: 1; overflow-y: auto; padding: 1rem 1.25rem; } .sw-drawer__body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5); }
/* ── Tabs ──────────────────────────────────── */ /* ── Tabs ──────────────────────────────────── */
@@ -241,7 +243,7 @@
} }
.sw-tabs__scroll::-webkit-scrollbar { display: none; } .sw-tabs__scroll::-webkit-scrollbar { display: none; }
.sw-tabs__tab { .sw-tabs__tab {
padding: 0.5rem 0.9rem; background: none; border: none; padding: var(--sp-2h) var(--sp-3); background: none; border: none;
border-bottom: 2px solid transparent; color: var(--text-2); border-bottom: 2px solid transparent; color: var(--text-2);
font-family: var(--font); font-size: 0.85rem; font-weight: 500; font-family: var(--font); font-size: 0.85rem; font-weight: 500;
cursor: pointer; white-space: nowrap; cursor: pointer; white-space: nowrap;
@@ -262,7 +264,7 @@
.sw-dropdown { position: relative; display: inline-block; } .sw-dropdown { position: relative; display: inline-block; }
.sw-dropdown__trigger { .sw-dropdown__trigger {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
gap: 0.5rem; width: 100%; padding: 0.45rem 0.7rem; gap: var(--sp-2); width: 100%; padding: var(--sp-2) var(--sp-3);
background: var(--input-bg); color: var(--text); background: var(--input-bg); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius); border: 1px solid var(--border); border-radius: var(--radius);
font-family: var(--font); font-size: 0.875rem; cursor: pointer; font-family: var(--font); font-size: 0.875rem; cursor: pointer;
@@ -278,13 +280,43 @@
border-radius: var(--radius); box-shadow: var(--shadow-lg); border-radius: var(--radius); box-shadow: var(--shadow-lg);
animation: sw-fade-in 0.1s ease; animation: sw-fade-in 0.1s ease;
} }
.sw-dropdown__search { margin: 4px; width: calc(100% - 8px); } .sw-dropdown__search { margin: var(--sp-1); width: calc(100% - var(--sp-2)); }
.sw-dropdown__option { .sw-dropdown__option {
padding: 0.4rem 0.75rem; cursor: pointer; font-size: 0.85rem; padding: var(--sp-1h) var(--sp-3); cursor: pointer; font-size: 0.85rem;
color: var(--text); transition: background var(--transition); color: var(--text); transition: background var(--transition);
} }
.sw-dropdown__option:hover, .sw-dropdown__option--focused { background: var(--bg-hover); } .sw-dropdown__option:hover, .sw-dropdown__option--focused { background: var(--bg-hover); }
.sw-dropdown__option--selected { color: var(--accent); font-weight: 500; } .sw-dropdown__option--selected { color: var(--accent); font-weight: 500; }
.sw-dropdown__option--disabled { opacity: 0.4; cursor: not-allowed; } .sw-dropdown__option--disabled { opacity: 0.4; cursor: not-allowed; }
.sw-dropdown__option--disabled:hover { background: transparent; } .sw-dropdown__option--disabled:hover { background: transparent; }
.sw-dropdown__empty { padding: 0.75rem; text-align: center; color: var(--text-3); font-size: 0.85rem; } .sw-dropdown__empty { padding: var(--sp-3); text-align: center; color: var(--text-3); font-size: 0.85rem; }
/* ── Focus Indicators ─────────────────────── */
.sw-btn:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
.sw-input:focus-visible {
outline: 2px solid var(--accent); outline-offset: -1px;
}
.sw-dropdown__trigger:focus-visible {
outline: 2px solid var(--accent); outline-offset: 2px;
}
.sw-menu__item:focus-visible {
outline: 2px solid var(--accent); outline-offset: -2px;
}
.sw-tabs__tab:focus-visible {
outline: 2px solid var(--accent); outline-offset: -2px;
}
/* ── Mobile Touch Targets (44px minimum) ──── */
@media (max-width: 768px) {
.sw-banner__close { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
.sw-toast__close { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
.sw-dialog__close { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
.sw-drawer__close { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
.sw-tabs__arrow { min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
.sw-tabs__tab { min-height: 44px; }
.sw-dropdown__option { min-height: 44px; display: flex; align-items: center; }
}

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,39 @@
Theme variables, reset, base styles Theme variables, reset, base styles
──────────────────────────────────────────── */ ──────────────────────────────────────────── */
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=JetBrains+Mono:wght@400;500&display=swap'); /* ── Self-hosted fonts (zero external dependencies) ── */
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url('../fonts/dm-sans-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url('../fonts/dm-sans-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400 500;
font-display: swap;
src: url('../fonts/jetbrains-mono-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400 500;
font-display: swap;
src: url('../fonts/jetbrains-mono-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* ========================================== /* ==========================================
Armature Armature
@@ -15,6 +47,7 @@
--bg-surface: #18181b; --bg-surface: #18181b;
--bg-raised: #222227; --bg-raised: #222227;
--bg-hover: #2a2a30; --bg-hover: #2a2a30;
--bg-active: #2f2f38;
--bg-elevated: #42424e; --bg-elevated: #42424e;
--border: #2e2e35; --border: #2e2e35;
--border-elevated: #555560; --border-elevated: #555560;
@@ -22,11 +55,11 @@
--text: #e8e8ed; --text: #e8e8ed;
--text-2: #9898a8; --text-2: #9898a8;
--text-3: #6b6b7b; --text-3: #6b6b7b;
--accent: #6c9fff; --accent: #6493ed;
--accent-hover: #84b0ff; --accent-hover: #7ca8f5;
--accent-dim: rgba(108,159,255,0.12); --accent-dim: rgba(100,147,237,0.12);
--danger: #ef4444; --danger: #ef4444;
--success: #22c55e; --success: #1dab51;
--warning: #eab308; --warning: #eab308;
--purple: #a78bfa; --purple: #a78bfa;
--purple-dim: rgba(167,139,250,0.10); --purple-dim: rgba(167,139,250,0.10);
@@ -52,12 +85,13 @@
--overlay: rgba(0,0,0,0.55); --overlay: rgba(0,0,0,0.55);
--glass: rgba(255,255,255,0.03); --glass: rgba(255,255,255,0.03);
--input-bg: #1a1a1f; --input-bg: #1a1a1f;
--user-bubble: rgba(108,159,255,0.08); --user-bubble: rgba(100,147,237,0.08);
--danger-bg: rgba(239,68,68,0.12); --danger-bg: rgba(239,68,68,0.12);
--bg-code: #1a1a22; --bg-code: #1a1a22;
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5); --shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
/* ── Layout ──────────────────────────────── */ /* ── Layout ──────────────────────────────── */
--radius-sm: 4px;
--radius: 8px; --radius: 8px;
--radius-lg: 12px; --radius-lg: 12px;
--sidebar-w: 260px; --sidebar-w: 260px;
@@ -66,6 +100,25 @@
--mono: 'JetBrains Mono', 'Söhne Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace; --mono: 'JetBrains Mono', 'Söhne Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace;
--transition: 180ms ease; --transition: 180ms ease;
/* -- Spacing (4px grid + half-steps) ------- */
--sp-1: 0.25rem; /* 4px */
--sp-1h: 0.375rem; /* 6px */
--sp-2: 0.5rem; /* 8px */
--sp-2h: 0.625rem; /* 10px */
--sp-3: 0.75rem; /* 12px */
--sp-4: 1rem; /* 16px */
--sp-5: 1.25rem; /* 20px */
--sp-6: 1.5rem; /* 24px */
--sp-8: 2rem; /* 32px */
--sp-10: 2.5rem; /* 40px */
--sp-12: 3rem; /* 48px */
/* -- Breakpoints --------------------------
Mobile: max-width: 768px
Tablet: max-width: 1024px
Desktop: default (no query)
----------------------------------------- */
/* Banner system — set by JS from /api/v1/admin/settings/banner */ /* Banner system — set by JS from /api/v1/admin/settings/banner */
--banner-top-height: 0px; --banner-top-height: 0px;
--banner-bottom-height: 0px; --banner-bottom-height: 0px;
@@ -81,13 +134,14 @@
--bg-surface: #ffffff; --bg-surface: #ffffff;
--bg-raised: #eff0f3; --bg-raised: #eff0f3;
--bg-hover: #e5e6eb; --bg-hover: #e5e6eb;
--bg-active: #dddee5;
--bg-elevated: #ffffff; --bg-elevated: #ffffff;
--border: #d5d6dc; --border: #d5d6dc;
--border-elevated: #c0c0c8; --border-elevated: #c0c0c8;
--border-light: #c2c3cb; --border-light: #c2c3cb;
--text: #1a1a2e; --text: #1a1a2e;
--text-2: #555770; --text-2: #555770;
--text-3: #8b8da3; --text-3: #787a92;
--accent: #4a7cdb; --accent: #4a7cdb;
--accent-hover: #3968c4; --accent-hover: #3968c4;
--accent-dim: rgba(74,124,219,0.09); --accent-dim: rgba(74,124,219,0.09);
@@ -102,8 +156,8 @@
--accent-light: #2563eb; --accent-light: #2563eb;
--danger-light: #dc2626; --danger-light: #dc2626;
--success-light: #16a34a; --success-light: #128a3e;
--warning-light: #ca8a04; --warning-light: #a97200;
--danger-dim: rgba(220,38,38,0.09); --danger-dim: rgba(220,38,38,0.09);
--success-dim: rgba(22,163,74,0.09); --success-dim: rgba(22,163,74,0.09);
@@ -130,7 +184,7 @@ body { font-family: var(--font); background: var(--bg); color: var(--text); over
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; } * { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: var(--radius-sm); }
::-webkit-scrollbar-thumb:hover { background: var(--border-light); } ::-webkit-scrollbar-thumb:hover { background: var(--border-light); }
a { color: var(--accent); text-decoration: none; } a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; } a:hover { text-decoration: underline; }

View File

@@ -10,7 +10,7 @@
gap: 8px; gap: 8px;
padding: 8px 10px; padding: 8px 10px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: var(--radius);
margin-bottom: 4px; margin-bottom: 4px;
background: var(--bg-surface); background: var(--bg-surface);
cursor: grab; cursor: grab;
@@ -27,15 +27,15 @@
} }
.wf-stage-name { flex: 1; font-size: 13px; font-weight: 500; } .wf-stage-name { flex: 1; font-size: 13px; font-weight: 500; }
.wf-stage-row .badge { font-size: 10px; } .wf-stage-row .badge { font-size: 10px; }
.wf-stage-row .btn-small { opacity: 0; transition: opacity 0.15s; } .wf-stage-row .sw-btn--sm { opacity: 0; transition: opacity 0.15s; }
.wf-stage-row:hover .btn-small { opacity: 1; } .wf-stage-row:hover .sw-btn--sm { opacity: 1; }
/* ── Queue sidebar ───────────────────── */ /* ── Queue sidebar ───────────────────── */
.sb-queue-badge { .sb-queue-badge {
background: var(--accent); background: var(--accent);
color: #fff; color: #fff;
border-radius: 10px; border-radius: var(--radius-lg);
padding: 1px 7px; padding: 1px 7px;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
@@ -47,7 +47,7 @@
gap: 8px; gap: 8px;
padding: 6px 12px; padding: 6px 12px;
cursor: pointer; cursor: pointer;
border-radius: 6px; border-radius: var(--radius);
font-size: 13px; font-size: 13px;
transition: background 0.15s; transition: background 0.15s;
} }
@@ -55,16 +55,16 @@
.sb-queue-item.active { background: var(--accent-dim); color: var(--accent); } .sb-queue-item.active { background: var(--accent-dim); color: var(--accent); }
.sb-queue-icon { font-size: 14px; } .sb-queue-icon { font-size: 14px; }
.sb-queue-label { flex: 1; } .sb-queue-label { flex: 1; }
.sb-queue-item .btn-small { .sb-queue-item .sw-btn--sm {
opacity: 0; padding: 2px 6px; font-size: 11px; opacity: 0; padding: 2px 6px; font-size: 11px;
transition: opacity 0.15s; transition: opacity 0.15s;
} }
.sb-queue-item:hover .btn-small { opacity: 1; } .sb-queue-item:hover .sw-btn--sm { opacity: 1; }
/* Collapsed sidebar — show icon only */ /* Collapsed sidebar — show icon only */
.sidebar.collapsed .sb-queue-item { justify-content: center; padding: 5px 0; margin: 0 6px; gap: 0; } .sidebar.collapsed .sb-queue-item { justify-content: center; padding: 5px 0; margin: 0 6px; gap: 0; }
.sidebar.collapsed .sb-queue-label { display: none; } .sidebar.collapsed .sb-queue-label { display: none; }
.sidebar.collapsed .sb-queue-item .btn-small { display: none; } .sidebar.collapsed .sb-queue-item .sw-btn--sm { display: none; }
.sidebar.collapsed .sb-queue-badge { display: none !important; } .sidebar.collapsed .sb-queue-badge { display: none !important; }
/* Unpin button */ /* Unpin button */
@@ -79,7 +79,7 @@
/* Browse workflows button */ /* Browse workflows button */
.sb-queue-browse { .sb-queue-browse {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 6px 12px; cursor: pointer; border-radius: 6px; padding: 6px 12px; cursor: pointer; border-radius: var(--radius);
font-size: 12px; color: var(--text-3); transition: background 0.15s; font-size: 12px; color: var(--text-3); transition: background 0.15s;
} }
.sb-queue-browse:hover { background: var(--bg-raised); color: var(--text-2); } .sb-queue-browse:hover { background: var(--bg-raised); color: var(--text-2); }
@@ -90,7 +90,7 @@
.wf-browse-dialog { max-height: 300px; overflow-y: auto; } .wf-browse-dialog { max-height: 300px; overflow-y: auto; }
.wf-browse-row { .wf-browse-row {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 8px 12px; cursor: pointer; border-radius: 6px; padding: 8px 12px; cursor: pointer; border-radius: var(--radius);
font-size: 13px; transition: background 0.15s; font-size: 13px; transition: background 0.15s;
} }
.wf-browse-row:hover { background: var(--bg-raised); } .wf-browse-row:hover { background: var(--bg-raised); }
@@ -109,7 +109,7 @@
/* ── Admin badges ────────────────────── */ /* ── Admin badges ────────────────────── */
.badge { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: var(--bg-raised); color: var(--text-2); } .badge { font-size: 11px; padding: 2px 8px; border-radius: var(--radius-lg); background: var(--bg-raised); color: var(--text-2); }
.badge-ok { background: rgba(46, 160, 67, 0.15); color: #2ea043; } .badge-ok { background: rgba(46, 160, 67, 0.15); color: #2ea043; }
.badge-warn { background: rgba(210, 153, 34, 0.15); color: #d29922; } .badge-warn { background: rgba(210, 153, 34, 0.15); color: #d29922; }
@@ -143,14 +143,14 @@
.wf-bar-status { .wf-bar-status {
font-size: 11px; font-size: 11px;
padding: 1px 8px; padding: 1px 8px;
border-radius: 10px; border-radius: var(--radius-lg);
} }
.wf-bar-status.active { background: var(--accent-dim); color: var(--accent); } .wf-bar-status.active { background: var(--accent-dim); color: var(--accent); }
.wf-bar-status.completed { background: var(--success-dim); color: var(--success-light); } .wf-bar-status.completed { background: var(--success-dim); color: var(--success-light); }
.wf-bar-status.stale { background: var(--warning-dim); color: var(--warning-light); } .wf-bar-status.stale { background: var(--warning-dim); color: var(--warning-light); }
.wf-bar-spacer { flex: 1; } .wf-bar-spacer { flex: 1; }
.wf-bar-actions { display: flex; gap: 6px; } .wf-bar-actions { display: flex; gap: 6px; }
.wf-bar-actions .btn-small { font-size: 11px; padding: 3px 10px; } .wf-bar-actions .sw-btn--sm { font-size: 11px; padding: 3px 10px; }
/* DnD stage reorder feedback */ /* DnD stage reorder feedback */
.wf-stage-row.dragging { opacity: 0.4; } .wf-stage-row.dragging { opacity: 0.4; }
@@ -164,7 +164,7 @@
gap: 6px; gap: 6px;
padding: 6px 8px; padding: 6px 8px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: var(--radius);
margin-bottom: 4px; margin-bottom: 4px;
background: var(--bg-surface); background: var(--bg-surface);
flex-wrap: wrap; flex-wrap: wrap;
@@ -177,7 +177,7 @@
font-size: 12px; font-size: 12px;
padding: 4px 6px; padding: 4px 6px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 4px; border-radius: var(--radius-sm);
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
} }

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

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

View File

@@ -288,6 +288,7 @@ export function createDomains(restClient) {
// ── Users ────────────────────────────── // ── Users ──────────────────────────────
users: { users: {
search: (q) => rc.get('/api/v1/users/search' + _qs({ q })), search: (q) => rc.get('/api/v1/users/search' + _qs({ q })),
resolve: (ids) => rc.get('/api/v1/users/resolve' + _qs({ ids: ids.join(',') })),
}, },
presence: { presence: {

View File

@@ -36,15 +36,18 @@ export function createAuth() {
// ── Token persistence ─────────────────────── // ── Token persistence ───────────────────────
let _cookieMaxAge = 604800; // default 7 days, updated from server response
function _saveTokens() { function _saveTokens() {
localStorage.setItem(_storageKey, JSON.stringify({ localStorage.setItem(_storageKey, JSON.stringify({
accessToken: _accessToken, accessToken: _accessToken,
refreshToken: _refreshToken, refreshToken: _refreshToken,
user: _user, user: _user,
cookieMaxAge: _cookieMaxAge,
})); }));
// Cookie sync for Go template page auth // Cookie sync for Go template page auth
if (_accessToken) { if (_accessToken) {
document.cookie = `arm_token=${_accessToken}; path=/; max-age=900; SameSite=Strict`; document.cookie = `arm_token=${_accessToken}; path=/; max-age=${_cookieMaxAge}; SameSite=Strict`;
} else { } else {
document.cookie = 'arm_token=; path=/; max-age=0'; document.cookie = 'arm_token=; path=/; max-age=0';
} }
@@ -57,6 +60,7 @@ export function createAuth() {
_accessToken = saved.accessToken || null; _accessToken = saved.accessToken || null;
_refreshToken = saved.refreshToken || null; _refreshToken = saved.refreshToken || null;
_user = saved.user || null; _user = saved.user || null;
if (saved.cookieMaxAge) _cookieMaxAge = saved.cookieMaxAge;
} }
} catch (_) { /* corrupt storage */ } } catch (_) { /* corrupt storage */ }
} }
@@ -81,6 +85,7 @@ export function createAuth() {
_accessToken = data.access_token; _accessToken = data.access_token;
_refreshToken = data.refresh_token; _refreshToken = data.refresh_token;
_user = data.user; _user = data.user;
if (data.refresh_expires_in) _cookieMaxAge = data.refresh_expires_in;
_saveTokens(); _saveTokens();
_scheduleRefresh(data.expires_in || 900); _scheduleRefresh(data.expires_in || 900);
} }
@@ -149,8 +154,8 @@ export function createAuth() {
/** /**
* Login with credentials. Stores tokens, fetches permissions, emits event. * Login with credentials. Stores tokens, fetches permissions, emits event.
*/ */
async login(login, password) { async login(login, password, keepLogin = false) {
const data = await _restClient.post('/api/v1/auth/login', { login, password }); const data = await _restClient.post('/api/v1/auth/login', { login, password, keep_login: keepLogin });
_setAuth(data); _setAuth(data);
await _fetchPermissions(); await _fetchPermissions();
_emit('auth.login', { user: auth.user }, { localOnly: true }); _emit('auth.login', { user: auth.user }, { localOnly: true });
@@ -245,9 +250,30 @@ export function createAuth() {
if (_accessToken) { if (_accessToken) {
_emit('auth.boot', { user: auth.user }, { localOnly: true }); _emit('auth.boot', { user: auth.user }, { localOnly: true });
auth._startActivityTracking();
} }
}, },
// ── Activity Ping (idle-timeout support) ──
/**
* Start activity tracking. Sends POST /api/v1/auth/activity
* on user interaction, debounced to max once per minute.
*/
_startActivityTracking() {
let lastPing = 0;
const INTERVAL = 60_000; // 1 minute
const ping = () => {
if (!_accessToken) return;
const now = Date.now();
if (now - lastPing < INTERVAL) return;
lastPing = now;
_restClient.post('/api/v1/auth/activity', {}).catch(() => {});
};
document.addEventListener('click', ping, { passive: true });
document.addEventListener('keydown', ping, { passive: true });
},
// ── Internal (called by rest-client / index.js) ── // ── Internal (called by rest-client / index.js) ──
_getToken() { return _accessToken; }, _getToken() { return _accessToken; },

View File

@@ -24,6 +24,7 @@ import { createActions } from './actions.js';
import { createRealtime } from './realtime.js'; import { createRealtime } from './realtime.js';
import { createRenderers } from './renderers.js'; import { createRenderers } from './renderers.js';
import { createMarkdown } from './markdown.js'; import { createMarkdown } from './markdown.js';
import { createUsers } from './users.js';
import { confirm } from '../primitives/confirm.js'; import { confirm } from '../primitives/confirm.js';
import { prompt } from '../primitives/prompt.js'; import { prompt } from '../primitives/prompt.js';
@@ -117,21 +118,17 @@ export async function boot() {
// Markdown — unified renderer (marked + DOMPurify + sw.renderers pipeline) // Markdown — unified renderer (marked + DOMPurify + sw.renderers pipeline)
sw.markdown = markdown; sw.markdown = markdown;
// Users — identity resolution with local caching
sw.users = createUsers(restClient);
// Shell helpers — imperative confirm/prompt backed by primitives // Shell helpers — imperative confirm/prompt backed by primitives
sw.confirm = confirm; sw.confirm = confirm;
sw.prompt = prompt; sw.prompt = prompt;
// Shell — layout utilities (decouples surface code from shell DOM) // Shell — layout utilities (decouples surface code from shell DOM)
const _shell = { const _shell = {
/** CSS transform scale on #surfaceInner (appearance zoom). */ /** @deprecated v0.6.10 — CSS zoom handles reflow; no scale correction needed. */
getScale() { getScale() { return 1; },
const el = document.getElementById('surfaceInner');
if (!el) return 1;
const t = getComputedStyle(el).transform;
if (!t || t === 'none') return 1;
const m = t.match(/matrix\(([^,]+)/);
return m ? parseFloat(m[1]) || 1 : 1;
},
/** Topbar — set after dynamic import below */ /** Topbar — set after dynamic import below */
Topbar: null, Topbar: null,
}; };

View File

@@ -103,7 +103,7 @@ export function createMarkdown(renderers) {
renderer(token) { renderer(token) {
const target = _escapeAttr(token.target); const target = _escapeAttr(token.target);
const display = _escapeHtml(token.display); const display = _escapeHtml(token.display);
return `<a class="wikilink" data-wikilink="${target}" href="javascript:void(0)">${display}</a>`; return `<a class="ext-notes-wikilink" data-wikilink="${target}" href="javascript:void(0)">${display}</a>`;
}, },
}], }],

104
src/js/sw/sdk/users.js Normal file
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 * AppShell — Layer 1 layout scaffold
* *
* ┌─────────────────────────────────┐ ← fixed banner (top, optional) * ┌─────────────────────────────────┐ ← fixed banner (top, optional)
@@ -22,20 +24,10 @@
const { html } = window; const { html } = window;
const { useState, useEffect, useRef, useReducer } = hooks; const { useState, useEffect, useRef, useReducer } = hooks;
/** @deprecated v0.6.10 — Banner height is measured by template scripts in base.html. */
function ShellBanner({ position, text, variant = 'info' }) { function ShellBanner({ position, text, variant = 'info' }) {
const ref = useRef(null);
const prop = position === 'top' ? '--banner-top-height' : '--banner-bottom-height';
useEffect(() => {
if (ref.current) {
const h = ref.current.offsetHeight + 'px';
document.documentElement.style.setProperty(prop, h);
}
return () => document.documentElement.style.setProperty(prop, '0px');
}, [text, prop]);
return html` return html`
<div class="sw-shell__banner sw-shell__banner--${position} sw-shell__banner--${variant}" ref=${ref}> <div class="sw-shell__banner sw-shell__banner--${position} sw-shell__banner--${variant}">
<span class="sw-shell__banner-text">${text}</span> <span class="sw-shell__banner-text">${text}</span>
</div> </div>
`; `;

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 * 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 * SurfaceViewport — container where the active surface renders
* *
* Deliberately thin. Error boundaries and * Deliberately thin. Error boundaries and

View File

@@ -77,9 +77,9 @@ export default function AuditSection() {
</div> </div>
<div style="margin-top:12px;display:flex;align-items:center;gap:8px;"> <div style="margin-top:12px;display:flex;align-items:center;gap:8px;">
<button class="btn-small" disabled=${page <= 1} onClick=${() => setPage(p => p - 1)}>\u2190 Prev</button> <button class="sw-btn sw-btn--secondary sw-btn--sm" disabled=${page <= 1} onClick=${() => setPage(p => p - 1)}>\u2190 Prev</button>
<span class="text-muted" style="font-size:12px;">Page ${page} of ${totalPages} (${total} total)</span> <span class="text-muted" style="font-size:12px;">Page ${page} of ${totalPages} (${total} total)</span>
<button class="btn-small" disabled=${page >= totalPages} onClick=${() => setPage(p => p + 1)}>Next \u2192</button> <button class="sw-btn sw-btn--secondary sw-btn--sm" disabled=${page >= totalPages} onClick=${() => setPage(p => p + 1)}>Next \u2192</button>
</div> </div>
` `
} }

View File

@@ -118,13 +118,13 @@ export default function BackupSection() {
return html` return html`
<div> <div>
<div style="display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;"> <div style="display:flex;gap:8px;margin-bottom:20px;flex-wrap:wrap;">
<button class="btn btn-primary" onclick=${() => createBackup(false)} disabled=${creating || restoring}> <button class="sw-btn sw-btn--primary sw-btn--md" onclick=${() => createBackup(false)} disabled=${creating || restoring}>
${creating ? 'Creating\u2026' : 'Download Backup'} ${creating ? 'Creating\u2026' : 'Download Backup'}
</button> </button>
<button class="btn" onclick=${() => createBackup(true)} disabled=${creating || restoring}> <button class="btn" onclick=${() => createBackup(true)} disabled=${creating || restoring}>
${creating ? 'Creating\u2026' : 'Save to Server'} ${creating ? 'Creating\u2026' : 'Save to Server'}
</button> </button>
<button class="btn btn-danger" onclick=${restoreBackup} disabled=${creating || restoring}> <button class="sw-btn sw-btn--danger sw-btn--md" onclick=${restoreBackup} disabled=${creating || restoring}>
${restoring ? 'Restoring\u2026' : 'Restore from File'} ${restoring ? 'Restoring\u2026' : 'Restore from File'}
</button> </button>
</div> </div>
@@ -149,8 +149,8 @@ export default function BackupSection() {
<td>${formatBytes(b.size)}</td> <td>${formatBytes(b.size)}</td>
<td>${formatDate(b.created_at)}</td> <td>${formatDate(b.created_at)}</td>
<td> <td>
<button class="btn btn-sm" onclick=${() => downloadBackup(b.name)}>Download</button> <button class="sw-btn sw-btn--secondary sw-btn--sm" onclick=${() => downloadBackup(b.name)}>Download</button>
<button class="btn btn-sm btn-danger" onclick=${() => deleteBackup(b.name)} style="margin-left:4px;">Delete</button> <button class="sw-btn sw-btn--danger sw-btn--sm" onclick=${() => deleteBackup(b.name)} style="margin-left:4px;">Delete</button>
</td> </td>
</tr> </tr>
`)} `)}

View File

@@ -38,7 +38,7 @@ export default function BroadcastSection() {
<option value="critical">Critical</option> <option value="critical">Critical</option>
</select> </select>
</div> </div>
<button class="btn-md btn-primary" onClick=${send} disabled=${sending}>${sending ? 'Sending\u2026' : 'Send Broadcast'}</button> <button class="sw-btn sw-btn--primary sw-btn--md" onClick=${send} disabled=${sending}>${sending ? 'Sending\u2026' : 'Send Broadcast'}</button>
</div> </div>
`; `;
} }

View File

@@ -114,7 +114,7 @@ export default function ConnectionsSection() {
return html` return html`
<div style="margin-bottom:12px;display:flex;align-items:center;gap:8px;"> <div style="margin-bottom:12px;display:flex;align-items:center;gap:8px;">
<button class="btn-md btn-primary" onClick=${openAdd} <button class="sw-btn sw-btn--primary sw-btn--md" onClick=${openAdd}
disabled=${!connTypes.length}>+ Add Connection</button> disabled=${!connTypes.length}>+ Add Connection</button>
${!connTypes.length && html` ${!connTypes.length && html`
<span style="color:var(--text-3);font-size:12px;"> <span style="color:var(--text-3);font-size:12px;">
@@ -165,11 +165,11 @@ export default function ConnectionsSection() {
</div> </div>
`)} `)}
<div style="display:flex;gap:8px;margin-top:12px;"> <div style="display:flex;gap:8px;margin-top:12px;">
<button class="btn-md btn-primary" disabled=${saving} <button class="sw-btn sw-btn--primary sw-btn--md" disabled=${saving}
onClick=${save}> onClick=${save}>
${saving ? 'Saving\u2026' : editId ? 'Update' : 'Add'} ${saving ? 'Saving\u2026' : editId ? 'Update' : 'Add'}
</button> </button>
<button class="btn-md btn-secondary" onClick=${cancel}>Cancel</button> <button class="sw-btn sw-btn--secondary sw-btn--md" onClick=${cancel}>Cancel</button>
</div> </div>
</div> </div>
`} `}
@@ -196,7 +196,7 @@ export default function ConnectionsSection() {
<button class="icon-btn" title="Edit" onClick=${() => openEdit(c)}> <button class="icon-btn" title="Edit" onClick=${() => openEdit(c)}>
\u{270F}\u{FE0F} \u{270F}\u{FE0F}
</button> </button>
<button class="icon-btn icon-btn-danger" title="Delete" <button class="icon-btn icon-sw-btn sw-btn--danger sw-btn--md" title="Delete"
onClick=${() => del(c)}> onClick=${() => del(c)}>
\u{1F5D1} \u{1F5D1}
</button> </button>

View File

@@ -112,11 +112,11 @@ export default function GroupsSection() {
if (detail) return html` if (detail) return html`
<div> <div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button class="btn-small" onClick=${() => setDetail(null)}>\u2190 Back</button> <button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setDetail(null)}>\u2190 Back</button>
<h4 style="margin:0;">${detail.name}</h4> <h4 style="margin:0;">${detail.name}</h4>
<span class="text-muted" style="font-size:12px;">${detail.scope}</span> <span class="text-muted" style="font-size:12px;">${detail.scope}</span>
<div style="flex:1"></div> <div style="flex:1"></div>
<button class="btn-small btn-danger" onClick=${deleteGroup}>Delete</button> <button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${deleteGroup}>Delete</button>
</div> </div>
<div class="settings-section" style="margin-bottom:16px;"> <div class="settings-section" style="margin-bottom:16px;">
@@ -134,7 +134,7 @@ export default function GroupsSection() {
</div> </div>
<div style="margin-bottom:16px;"> <div style="margin-bottom:16px;">
<button class="btn-small btn-primary" onClick=${savePermissions}>Save Permissions</button> <button class="sw-btn sw-btn--primary sw-btn--sm" onClick=${savePermissions}>Save Permissions</button>
</div> </div>
<hr style="border:none;border-top:1px solid var(--border);margin:16px 0;" /> <hr style="border:none;border-top:1px solid var(--border);margin:16px 0;" />
@@ -143,8 +143,8 @@ export default function GroupsSection() {
<div class="admin-list"> <div class="admin-list">
${members.map(m => html` ${members.map(m => html`
<div class="admin-surface-row" key=${m.id || m.user_id}> <div class="admin-surface-row" key=${m.id || m.user_id}>
<span style="flex:1;">${m.username || m.user_id}</span> <span style="flex:1;">${m.display_name || m.username || 'Unknown'}</span>
<button class="btn-small btn-danger" onClick=${() => removeMember(m.user_id || m.id)}>Remove</button> <button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => removeMember(m.user_id || m.id)}>Remove</button>
</div> </div>
`)} `)}
</div> </div>
@@ -155,17 +155,17 @@ export default function GroupsSection() {
<div class="form-group"><label>User</label> <div class="form-group"><label>User</label>
<select name="userId"> <select name="userId">
<option value="">Select\u2026</option> <option value="">Select\u2026</option>
${allUsers.map(u => html`<option key=${u.id} value=${u.id}>${u.username}</option>`)} ${allUsers.map(u => html`<option key=${u.id} value=${u.id}>${u.display_name || u.username}</option>`)}
</select> </select>
</div> </div>
</div> </div>
<div class="form-row" style="margin-top:8px;gap:8px;"> <div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Add</button> <button type="submit" class="sw-btn sw-btn--primary sw-btn--sm">Add</button>
<button type="button" class="btn-small" onClick=${() => setShowAddMember(false)}>Cancel</button> <button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setShowAddMember(false)}>Cancel</button>
</div> </div>
</form> </form>
` `
: html`<button class="btn-small" style="margin-top:8px;" onClick=${() => setShowAddMember(true)}>+ Add Member</button>` : html`<button class="sw-btn sw-btn--secondary sw-btn--sm" style="margin-top:8px;" onClick=${() => setShowAddMember(true)}>+ Add Member</button>`
} }
</div> </div>
`; `;
@@ -174,7 +174,7 @@ export default function GroupsSection() {
<div> <div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1"></div> <div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button> <button class="sw-btn sw-btn--primary sw-btn--md" onClick=${() => setShowCreate(true)}>+ Add</button>
</div> </div>
${showCreate && html` ${showCreate && html`
@@ -187,8 +187,8 @@ export default function GroupsSection() {
</div> </div>
</div> </div>
<div class="form-row" style="margin-top:8px;gap:8px;"> <div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Create</button> <button type="submit" class="sw-btn sw-btn--primary sw-btn--sm">Create</button>
<button type="button" class="btn-small" onClick=${() => setShowCreate(false)}>Cancel</button> <button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setShowCreate(false)}>Cancel</button>
</div> </div>
</form> </form>
`} `}

View File

@@ -252,10 +252,10 @@ export default function PackagesSection() {
options=${TYPE_OPTIONS.map(t => ({ label: t.charAt(0).toUpperCase() + t.slice(1), value: t }))} options=${TYPE_OPTIONS.map(t => ({ label: t.charAt(0).toUpperCase() + t.slice(1), value: t }))}
/> />
<span style="flex:1;" /> <span style="flex:1;" />
<button class="btn-small" onClick=${uploadPackage} disabled=${installing}> <button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${uploadPackage} disabled=${installing}>
${installing ? 'Installing\u2026' : 'Install Package'} ${installing ? 'Installing\u2026' : 'Install Package'}
</button> </button>
<button class="btn-small" onClick=${toggleRegistry}> <button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${toggleRegistry}>
${registryOpen ? 'Close Registry' : 'Browse Registry'} ${registryOpen ? 'Close Registry' : 'Browse Registry'}
</button> </button>
</div> </div>
@@ -276,28 +276,28 @@ export default function PackagesSection() {
${pkg.is_system && html`<span class="badge" style="margin-left:4px;">system</span>`} ${pkg.is_system && html`<span class="badge" style="margin-left:4px;">system</span>`}
</div> </div>
<div class="admin-actions-cell"> <div class="admin-actions-cell">
<button class="btn-small" <button class="sw-btn sw-btn--secondary sw-btn--sm"
onClick=${() => toggleEnabled(pkg)} onClick=${() => toggleEnabled(pkg)}
disabled=${CORE_IDS.has(pkg.id) || pkg.status === 'dormant' || pkg.status === 'pending_review'} disabled=${CORE_IDS.has(pkg.id) || pkg.status === 'dormant' || pkg.status === 'pending_review'}
title=${pkg.status === 'dormant' ? 'Requires chat' : pkg.status === 'pending_review' ? 'Grant permissions to activate' : ''}> title=${pkg.status === 'dormant' ? 'Requires chat' : pkg.status === 'pending_review' ? 'Grant permissions to activate' : ''}>
${pkg.enabled ? 'Disable' : 'Enable'} ${pkg.enabled ? 'Disable' : 'Enable'}
</button> </button>
${hasManifestSettings(pkg) && html` ${hasManifestSettings(pkg) && html`
<button class="btn-small" onClick=${() => toggleSettings(pkg.id)}> <button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => toggleSettings(pkg.id)}>
${expandedId === pkg.id ? 'Close' : 'Settings'} ${expandedId === pkg.id ? 'Close' : 'Settings'}
</button> </button>
`} `}
${hasDeclaredPerms(pkg) && html` ${hasDeclaredPerms(pkg) && html`
<button class="btn-small" onClick=${() => togglePerms(pkg.id)}> <button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => togglePerms(pkg.id)}>
${permsId === pkg.id ? 'Close' : 'Permissions'} ${permsId === pkg.id ? 'Close' : 'Permissions'}
</button> </button>
`} `}
${pkg.source !== 'core' && html` ${pkg.source !== 'core' && html`
<button class="btn-small" onClick=${() => updatePkg(pkg)}>Update</button> <button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => updatePkg(pkg)}>Update</button>
`} `}
<button class="btn-small" onClick=${() => exportPkg(pkg.id)}>Export</button> <button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => exportPkg(pkg.id)}>Export</button>
${pkg.source !== 'core' && !pkg.is_system && html` ${pkg.source !== 'core' && !pkg.is_system && html`
<button class="btn-small btn-danger" onClick=${() => deletePkg(pkg)}>Delete</button> <button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => deletePkg(pkg)}>Delete</button>
`} `}
</div> </div>
</div> </div>
@@ -317,7 +317,7 @@ export default function PackagesSection() {
style="flex:1;padding:4px 8px;background:var(--bg-1,var(--bg));color:var(--text);border:1px solid var(--border);border-radius:4px;font-size:12px;" /> style="flex:1;padding:4px 8px;background:var(--bg-1,var(--bg));color:var(--text);border:1px solid var(--border);border-radius:4px;font-size:12px;" />
</div> </div>
`)} `)}
<button class="btn-small" style="margin-top:4px;" onClick=${() => saveSettings(pkg.id)}>Save</button> <button class="sw-btn sw-btn--secondary sw-btn--sm" style="margin-top:4px;" onClick=${() => saveSettings(pkg.id)}>Save</button>
` `
} }
</div> </div>
@@ -333,7 +333,7 @@ export default function PackagesSection() {
${perms.map(p => html` ${perms.map(p => html`
<div key=${p.permission} style="display:flex;gap:8px;align-items:center;margin-bottom:4px;"> <div key=${p.permission} style="display:flex;gap:8px;align-items:center;margin-bottom:4px;">
<code style="min-width:140px;font-size:12px;">${p.permission}</code> <code style="min-width:140px;font-size:12px;">${p.permission}</code>
<button class="btn-small ${p.granted ? 'btn-danger' : 'btn-primary'}" <button class="sw-btn sw-btn--secondary sw-btn--sm ${p.granted ? 'sw-btn sw-btn--danger sw-btn--md' : 'sw-btn sw-btn--primary sw-btn--md'}"
style="min-width:64px;font-size:11px;" style="min-width:64px;font-size:11px;"
onClick=${() => p.granted ? revokePerm(pkg.id, p.permission) : grantPerm(pkg.id, p.permission)}> onClick=${() => p.granted ? revokePerm(pkg.id, p.permission) : grantPerm(pkg.id, p.permission)}>
${p.granted ? 'Revoke' : 'Grant'} ${p.granted ? 'Revoke' : 'Grant'}
@@ -345,7 +345,7 @@ export default function PackagesSection() {
`)} `)}
</div> </div>
${perms.some(p => !p.granted) && html` ${perms.some(p => !p.granted) && html`
<button class="btn-small btn-primary" onClick=${() => grantAllPerms(pkg.id)}>Grant All</button> <button class="sw-btn sw-btn--primary sw-btn--sm" onClick=${() => grantAllPerms(pkg.id)}>Grant All</button>
`} `}
` `
} }
@@ -376,7 +376,7 @@ export default function PackagesSection() {
</div> </div>
<div class="admin-actions-cell"> <div class="admin-actions-cell">
${typeBadge(rp.type)} ${typeBadge(rp.type)}
<button class="btn-small btn-primary" <button class="sw-btn sw-btn--primary sw-btn--sm"
onClick=${() => installFromRegistry(rp.download_url)}>Install</button> onClick=${() => installFromRegistry(rp.download_url)}>Install</button>
</div> </div>
</div> </div>

View File

@@ -37,6 +37,10 @@ export default function SettingsSection() {
footer_enabled: !!cfg_.footer?.enabled, footer_enabled: !!cfg_.footer?.enabled,
footer_text: cfg_.footer?.text || '', footer_text: cfg_.footer?.text || '',
package_registry_url: cfg_.package_registry?.url || '', package_registry_url: cfg_.package_registry?.url || '',
session_access_ttl: cfg_.session?.access_token_ttl || '15m',
session_refresh_ttl: cfg_.session?.refresh_token_ttl || '7d',
session_idle_enabled: !!cfg_.session?.idle_timeout,
session_idle_timeout: cfg_.session?.idle_timeout || '2h',
}); });
setVault(v); setVault(v);
} catch (e) { sw.toast(e.message, 'error'); } } catch (e) { sw.toast(e.message, 'error'); }
@@ -82,6 +86,13 @@ export default function SettingsSection() {
// Package Registry // Package Registry
await sw.api.admin.settings.update('package_registry', { value: { url: cfg.package_registry_url } }); await sw.api.admin.settings.update('package_registry', { value: { url: cfg.package_registry_url } });
// Session
await sw.api.admin.settings.update('session', { value: {
access_token_ttl: cfg.session_access_ttl,
refresh_token_ttl: cfg.session_refresh_ttl,
idle_timeout: cfg.session_idle_enabled ? cfg.session_idle_timeout : '',
}});
sw.toast('Settings saved', 'success'); sw.toast('Settings saved', 'success');
} catch (e) { sw.toast(e.message, 'error'); } } catch (e) { sw.toast(e.message, 'error'); }
finally { setSaving(false); } finally { setSaving(false); }
@@ -163,6 +174,48 @@ export default function SettingsSection() {
`} `}
</div> </div>
<div class="settings-section"><h3>Session</h3>
<div class="form-row">
<div class="form-group"><label>Access token TTL</label>
<select value=${cfg.session_access_ttl} onChange=${e => set('session_access_ttl', e.target.value)}>
<option value="5m">5 minutes</option>
<option value="10m">10 minutes</option>
<option value="15m">15 minutes (default)</option>
<option value="30m">30 minutes</option>
<option value="60m">60 minutes</option>
</select>
</div>
<div class="form-group"><label>Refresh token TTL</label>
<select value=${cfg.session_refresh_ttl} onChange=${e => set('session_refresh_ttl', e.target.value)}>
<option value="1h">1 hour</option>
<option value="12h">12 hours</option>
<option value="24h">24 hours</option>
<option value="7d">7 days (default)</option>
<option value="30d">30 days</option>
<option value="90d">90 days</option>
</select>
</div>
</div>
<span class="text-muted" style="font-size:12px;">Access token controls JWT lifetime. Refresh token controls how long sessions persist. "Keep me logged in" unchecked caps refresh at 24h.</span>
<div style="margin-top:8px;">
<label class="toggle-label"><input type="checkbox" checked=${cfg.session_idle_enabled} onChange=${e => set('session_idle_enabled', e.target.checked)} /><span class="toggle-track"></span><span>Enable idle timeout</span></label>
${cfg.session_idle_enabled && html`
<div class="form-group" style="margin-top:8px;"><label>Idle timeout</label>
<select value=${cfg.session_idle_timeout} onChange=${e => set('session_idle_timeout', e.target.value)}>
<option value="15m">15 minutes</option>
<option value="30m">30 minutes</option>
<option value="1h">1 hour</option>
<option value="2h">2 hours (default)</option>
<option value="4h">4 hours</option>
<option value="8h">8 hours</option>
<option value="24h">24 hours</option>
</select>
</div>
<span class="text-muted" style="font-size:12px;">Rejects token refresh if no user activity within the timeout period.</span>
`}
</div>
</div>
<div class="settings-section"><h3>Encryption Vault</h3> <div class="settings-section"><h3>Encryption Vault</h3>
${vault ${vault
? html`<div class="text-muted" style="font-size:12px;">Key set: ${vault.encryption_key_set ? 'Yes' : 'No'} \u2022 User vaults: ${vault.user_vaults_count ?? '?'}</div>` ? html`<div class="text-muted" style="font-size:12px;">Key set: ${vault.encryption_key_set ? 'Yes' : 'No'} \u2022 User vaults: ${vault.user_vaults_count ?? '?'}</div>`
@@ -178,11 +231,11 @@ export default function SettingsSection() {
</div> </div>
<div class="settings-section"><h3>Email</h3> <div class="settings-section"><h3>Email</h3>
<button class="btn-small" onClick=${testEmail}>Send Test Email</button> <button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${testEmail}>Send Test Email</button>
</div> </div>
<div style="display:flex;gap:8px;align-items:center;margin-top:16px;"> <div style="display:flex;gap:8px;align-items:center;margin-top:16px;">
<button class="btn-md btn-primary" onClick=${save} disabled=${saving}>${saving ? 'Saving\u2026' : 'Save Settings'}</button> <button class="sw-btn sw-btn--primary sw-btn--md" onClick=${save} disabled=${saving}>${saving ? 'Saving\u2026' : 'Save Settings'}</button>
</div> </div>
</div> </div>
`; `;

View File

@@ -61,7 +61,7 @@ export default function StorageSection() {
<div class="settings-section" style="margin-bottom:16px;"> <div class="settings-section" style="margin-bottom:16px;">
<h4>Orphaned Blobs</h4> <h4>Orphaned Blobs</h4>
<p class="text-muted" style="font-size:12px;">${orphans.count || 0} orphans found</p> <p class="text-muted" style="font-size:12px;">${orphans.count || 0} orphans found</p>
${(orphans.count || 0) > 0 && html`<button class="btn-small btn-primary" onClick=${cleanup}>Clean Up</button>`} ${(orphans.count || 0) > 0 && html`<button class="sw-btn sw-btn--primary sw-btn--sm" onClick=${cleanup}>Clean Up</button>`}
</div> </div>
`} `}

View File

@@ -94,10 +94,10 @@ export default function TeamsSection() {
if (detail) return html` if (detail) return html`
<div> <div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<button class="btn-small" onClick=${() => setDetail(null)}>\u2190 Back</button> <button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setDetail(null)}>\u2190 Back</button>
<h4 style="margin:0;">${detail.name}</h4> <h4 style="margin:0;">${detail.name}</h4>
<div style="flex:1"></div> <div style="flex:1"></div>
<button class="btn-small btn-danger" onClick=${() => deleteTeam(detail.id)}>Delete Team</button> <button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => deleteTeam(detail.id)}>Delete Team</button>
</div> </div>
${detail.description && html`<p class="text-muted" style="font-size:12px;margin-bottom:12px;">${detail.description}</p>`} ${detail.description && html`<p class="text-muted" style="font-size:12px;margin-bottom:12px;">${detail.description}</p>`}
@@ -106,13 +106,13 @@ export default function TeamsSection() {
<div class="admin-list"> <div class="admin-list">
${members.map(m => html` ${members.map(m => html`
<div class="admin-surface-row" key=${m.id}> <div class="admin-surface-row" key=${m.id}>
<span style="flex:1;">${m.username || m.user_id}</span> <span style="flex:1;">${m.display_name || m.username || 'Unknown'}</span>
<select value=${m.role} onChange=${e => updateMemberRole(m.id, e.target.value)} <select value=${m.role} onChange=${e => updateMemberRole(m.id, e.target.value)}
style="font-size:11px;padding:2px 4px;"> style="font-size:11px;padding:2px 4px;">
<option value="member">member</option> <option value="member">member</option>
<option value="admin">admin</option> <option value="admin">admin</option>
</select> </select>
<button class="btn-small btn-danger" onClick=${() => removeMember(m.id)}>Remove</button> <button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => removeMember(m.id)}>Remove</button>
</div> </div>
`)} `)}
</div> </div>
@@ -124,7 +124,7 @@ export default function TeamsSection() {
<div class="form-group"><label>User</label> <div class="form-group"><label>User</label>
<select name="userId"> <select name="userId">
<option value="">Select user\u2026</option> <option value="">Select user\u2026</option>
${allUsers.map(u => html`<option key=${u.id} value=${u.id}>${u.username}</option>`)} ${allUsers.map(u => html`<option key=${u.id} value=${u.id}>${u.display_name || u.username}</option>`)}
</select> </select>
</div> </div>
<div class="form-group"><label>Role</label> <div class="form-group"><label>Role</label>
@@ -132,12 +132,12 @@ export default function TeamsSection() {
</div> </div>
</div> </div>
<div class="form-row" style="margin-top:8px;gap:8px;"> <div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Add</button> <button type="submit" class="sw-btn sw-btn--primary sw-btn--sm">Add</button>
<button type="button" class="btn-small" onClick=${() => setShowAddMember(false)}>Cancel</button> <button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setShowAddMember(false)}>Cancel</button>
</div> </div>
</form> </form>
` `
: html`<button class="btn-small" style="margin-top:8px;" onClick=${() => setShowAddMember(true)}>+ Add Member</button>` : html`<button class="sw-btn sw-btn--secondary sw-btn--sm" style="margin-top:8px;" onClick=${() => setShowAddMember(true)}>+ Add Member</button>`
} }
</div> </div>
`; `;
@@ -147,7 +147,7 @@ export default function TeamsSection() {
<div> <div>
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
<div style="flex:1"></div> <div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button> <button class="sw-btn sw-btn--primary sw-btn--md" onClick=${() => setShowCreate(true)}>+ Add</button>
</div> </div>
${showCreate && html` ${showCreate && html`
@@ -157,8 +157,8 @@ export default function TeamsSection() {
<div class="form-group"><label>Description</label><input name="desc" placeholder="Optional" /></div> <div class="form-group"><label>Description</label><input name="desc" placeholder="Optional" /></div>
</div> </div>
<div class="form-row" style="margin-top:8px;gap:8px;"> <div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Create</button> <button type="submit" class="sw-btn sw-btn--primary sw-btn--sm">Create</button>
<button type="button" class="btn-small" onClick=${() => setShowCreate(false)}>Cancel</button> <button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setShowCreate(false)}>Cancel</button>
</div> </div>
</form> </form>
`} `}

View File

@@ -103,7 +103,7 @@ export default function UsersSection() {
<input placeholder="Search users\u2026" value=${search} onInput=${e => setSearch(e.target.value)} autocomplete="off" /> <input placeholder="Search users\u2026" value=${search} onInput=${e => setSearch(e.target.value)} autocomplete="off" />
</div> </div>
<div style="flex:1"></div> <div style="flex:1"></div>
<button class="btn-md btn-primary" onClick=${() => setShowCreate(true)}>+ Add</button> <button class="sw-btn sw-btn--primary sw-btn--md" onClick=${() => setShowCreate(true)}>+ Add</button>
</div> </div>
${showCreate && html` ${showCreate && html`
@@ -117,8 +117,8 @@ export default function UsersSection() {
</div> </div>
</div> </div>
<div class="form-row" style="margin-top:8px;gap:8px;"> <div class="form-row" style="margin-top:8px;gap:8px;">
<button type="submit" class="btn-small btn-primary">Create</button> <button type="submit" class="sw-btn sw-btn--primary sw-btn--sm">Create</button>
<button type="button" class="btn-small" onClick=${() => setShowCreate(false)}>Cancel</button> <button type="button" class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => setShowCreate(false)}>Cancel</button>
</div> </div>
</form> </form>
`} `}
@@ -128,20 +128,21 @@ export default function UsersSection() {
${filtered.map(u => html` ${filtered.map(u => html`
<div class="admin-surface-row" key=${u.id}> <div class="admin-surface-row" key=${u.id}>
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">
<strong>${u.username}</strong> <strong>${u.display_name || u.username}</strong>
<span class="text-muted" style="margin-left:8px;font-size:12px;">${u.display_name ? u.username : ''}</span>
<span class="text-muted" style="margin-left:8px;font-size:12px;">${u.email || ''}</span> <span class="text-muted" style="margin-left:8px;font-size:12px;">${u.email || ''}</span>
${' '}${statusBadge(u)} ${' '}${statusBadge(u)}
</div> </div>
<div class="admin-actions-cell"> <div class="admin-actions-cell">
${u.status === 'pending' && html` ${u.status === 'pending' && html`
<button class="btn-small btn-primary" onClick=${() => approveUser(u.id)}>Approve</button> <button class="sw-btn sw-btn--primary sw-btn--sm" onClick=${() => approveUser(u.id)}>Approve</button>
`} `}
${u.is_active ${u.is_active
? html`<button class="btn-small" onClick=${() => toggleActive(u.id, false)}>Disable</button>` ? html`<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => toggleActive(u.id, false)}>Disable</button>`
: html`<button class="btn-small" onClick=${() => toggleActive(u.id, true)}>Enable</button>` : html`<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => toggleActive(u.id, true)}>Enable</button>`
} }
<button class="btn-small" onClick=${() => resetPassword(u.id, u.username)}>Reset PW</button> <button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => resetPassword(u.id, u.username)}>Reset PW</button>
<button class="btn-small btn-danger" onClick=${() => deleteUser(u.id, u.username)}>Delete</button> <button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => deleteUser(u.id, u.username)}>Delete</button>
</div> </div>
</div> </div>
`)} `)}

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