* feat(frontend): add LazyBoundary for chunk-load failure recovery
When a lazy chunk fetch fails, the existing top-level ErrorBoundary shows
"Something went wrong" with a "Try again" CTA. "Try again" cannot succeed
against a chunk URL that no longer exists on the server (typical post-
deploy case where the user's tab was opened against an older bundle).
The right remedy is to reload the tab so the browser fetches the new
hashed chunks emitted by the current build.
Add LazyBoundary, a section-local error boundary that:
- Detects chunk-load errors via a substring union covering Chrome / Edge
("Failed to fetch dynamically imported module"), Safari ("Importing a
module script failed"), Firefox ("disallowed MIME type" thrown when a
deploy serves SPA index.html for a missing chunk URL), older Webpack
("Error loading dynamically imported module"), and Vite ("Loading
chunk/CSS chunk N failed").
- For chunk errors, renders a glass-card matching the LockCard aesthetic
with an AlertTriangle icon, a "This part of Sencho needs a reload"
message, and a Reload CTA that calls window.location.reload().
- For non-chunk runtime errors, falls back to "Something went wrong" +
the error message + a Try again CTA. Try again is safe on this path
because the lazy import has already resolved before the render error
fires.
- Logs to console.error in componentDidCatch so the underlying failure
is still observable.
- Has role="alert" so screen readers announce the failure.
Wrap every existing Suspense site (1 in SettingsPage, 7 in EditorLayout
including the security-history overlay, 1 in ResourcesView) with
LazyBoundary. The top-level ErrorBoundary remains the catch-all for
errors that escape the section-local boundary.
Includes a unit test enumerating each browser's documented chunk-load
message so a regression in any one runtime is caught early.
* fix(frontend): move isChunkLoadError to its own file to satisfy react-refresh/only-export-components
* fix(frontend): replace post-dismissal blur with click-to-restore pill
PaidGate and AdmiralGate fell through to a "blurred children + small
pill" render when a user clicked Dismiss on the full-page upsell, for
the next 24h. The blurred-children path rendered the gated subtree,
so any lazy chunk behind the gate (FleetView, AuditLogView, etc.)
fetched on click during the dismissal window even though the user
saw only an obscured preview. This was the last lazy-chunk leakage
path remaining after the recent splitting work; CapabilityGate's
short-circuit refactor closed the others.
Split the dismissed branch from the compact branch. Compact mode
(used for inline list-item locks like a single SSO provider card) is
unchanged: its blur is intentional UX and the IP exposure is minor
because the children are tiny inline UI. Dismissed mode now renders
only a small pill in an empty 200px-tall area, with no children
mounted, so the lazy chunks behind the gate never fetch during the
dismissal window.
The pill is a button: clicking it removes the dismissal flag from
localStorage and re-renders the full upsell card. Users who dismissed
accidentally or want to revisit pricing have a way back without
clearing site data manually.
The dismissal flow itself is preserved: users who want to mute the
upsell pressure for 24h still can, they just get the static pill
instead of a blurred preview during that window.
* test(e2e): narrow upgrade-pill locator to file-upload button
The dismissed PaidGate branch introduced in this PR now renders a
<button> (was a <div>), which matched the same /upgrade to unlock/i
regex as the FileUploadDropzone button. Narrowing to
/upgrade to unlock upload/i targets only the file-upload pill and
resolves the strict-mode locator ambiguity.
CapabilityGate previously rendered the gated children behind a blur
filter and overlay pill when the active node lacked the required
capability. Combined with the lazy-loaded views from the recent
splitting work, this meant the chunk for FleetView, AuditLogView,
HostConsole, etc. fetched on click even when the user could not use
the feature, defeating the click-time IP protection the lazy split
was meant to deliver. CapabilityGate was the only always-leaking gate
in the app.
Replace the blurred-children render with a clean glass lock card that
explains the version mismatch ("Fleet Management is not available on
this node. <node> is running v0.42.0. Upgrade the node to use this
feature."). Children are no longer rendered, so the lazy children
never mount and no chunk fetch happens. All 13 consumers (5 full-page
views, 7 settings sections, 1 inline panel in ResourcesView) get the
new behavior automatically; no consumer-side changes required.
Extract a shared LockCard primitive used by both CapabilityGate and
the existing TierLockedCard inside settings/SectionGate. The two were
95% identical (same glass-card chrome, same icon framing, same text
hierarchy) and would have drifted as the design evolved. The shared
primitive accepts an icon, title, and body, with an optional className
for layout overrides; the inner geometry is fixed so every lock state
in the app shares the same visual rhythm.
PaidGate and AdmiralGate still fall through to "blurred preview with
small pill" after a user clicks Dismiss on their full-page upsell
(24h localStorage window). That post-dismissal click-time leak is
intentionally out of scope here; closing it would require either
removing the dismissal flow or replacing the blurred-children render
with a static placeholder, both UX decisions deserving their own PR.
Extends the settings code-splitting from PR #870 to the full-screen
views in EditorLayout. Six paid views (HostConsole, FleetView,
AuditLogView, ScheduledOperationsView, AutoUpdateReadinessView,
GlobalObservabilityView) and the SecurityHistoryView overlay used to
ship statically into the main bundle, so every Community user
downloaded ~300 kB raw / ~80 kB gzip of paid feature code on first
page load even if they never clicked those tabs.
Convert each to a lazy() declaration and wrap the call site in
Suspense with a small ViewSkeleton fallback. SecurityHistoryView is
an always-mounted overlay, so it is also conditionally mounted on
its open state to keep the lazy import from firing on EditorLayout's
first render.
GlobalObservabilityView is a free-tier feature with no internal gate;
it is split here purely for the bundle-size win, not for IP
protection. The other paid views still have their existing PaidGate /
AdmiralGate / CapabilityGate wrappers, which render a blurred preview
with upsell card rather than short-circuiting. When a tier-locked or
capability-missing operator opens one of those tabs, the chunk fetches
to render the blurred preview. The gate-short-circuit refactor is a
separate follow-up.
Build evidence: 7 new chunks total ~308 kB raw / ~83 kB gzip; main
bundle shrunk from 1,468 kB / 407 kB gzip to 1,165 kB / 332 kB gzip.
Combined with PR #870, Community users save ~390 kB raw / ~107 kB
gzip on initial load.
Every paid-tier React settings section (Users, Webhooks, Security,
Labels, ApiTokens, Registries, CloudBackup, NotificationRouting) was
statically imported into the bundle Community installs download.
SectionGate's runtime tier check hid the components from view but did
not gate the download, so paid feature JSX, copy, error messages, and
prop interfaces shipped to every Community user. Anyone could open
DevTools and read the source.
Convert each paid section to a lazy() declaration that imports the
component module on demand, and remove the corresponding re-exports
from settings/index.ts so rollup actually splits the chunk (without
this, the static export path through the barrel collapses the lazy
import back into the main bundle and emits an INEFFECTIVE_DYNAMIC_
IMPORT warning).
Suspense sits outside SectionGate intentionally: SectionGate short-
circuits to TierLockedCard synchronously for locked tiers, so the
lazy children never mount and no fallback flashes. The skeleton only
appears for the brief window between an unlocked section's chunk
request and its first render.
Build evidence: 8 new chunks total ~89 kB raw / ~27 kB gzip; main
bundle shrunk from 1,537 kB / 421 kB gzip to 1,468 kB / 407 kB gzip.
No INEFFECTIVE_DYNAMIC_IMPORT warnings remain. Dev-server runtime
test: AccountSection (free, eager) loaded as request 221 on app boot;
CloudBackupSection (paid, lazy) loaded as request 351 only after
clicking the sidebar entry.
Non-settings paid views (FleetView, AuditLogView, etc.) are still
static and remain a follow-up.
* chore(repo): enforce Conventional Commits via husky and commitlint
release-please parses commit subjects on main to compute the next version
and regenerate CHANGELOG.md. A non-conforming commit silently breaks both,
so the format must be enforced at commit time, not by review.
Adds husky 9 to wire a commit-msg hook, commitlint with the conventional
config, and a small rule override (loosen subject length to 120 chars,
disable subject-case so existing imperative subjects keep passing). The
prepare script runs husky on npm install so contributors do not need to
configure it manually.
* ci: add dependency-review workflow
GitHub-native action that diffs the PR's manifests (package.json,
package-lock.json) against main and fails when a new transitive dep
introduces a high or critical CVE. Existing vulnerabilities tracked in
security/vex/sencho.openvex.json and the Trivy scan in ci.yml are not
re-flagged here.
Pinned to actions/dependency-review-action v4.9.0 by commit SHA. Comment
summaries are posted to the PR only on failure to keep the conversation
clean on green PRs.
* ci: add stale workflow for issues and pull requests
Marks issues and PRs as stale after 60 days of inactivity and closes 14
days later unless re-engaged. Issues labeled pinned, security, bug, or
tracking are exempt and never auto-closed; PRs labeled pinned or security
are exempt. Runs daily at 01:30 UTC and processes up to 30 items per run
to stay within the action's rate budget.
Pinned to actions/stale v10.2.0 by commit SHA.
* chore(repo): route new issues to docs, discussions, and security policy
Disables the blank-issue option and adds three contact links the issue
chooser surfaces above the bug-report and feature-request templates:
docs.sencho.io for setup questions, GitHub Discussions for open-ended
chat, and the repository security policy for private vulnerability
reports. This keeps the bug tracker focused on actionable bug reports
and feature requests instead of support questions.
* ci(docker): publish multi-arch image to GHCR alongside Docker Hub
Mirrors every released sencho and sencho-mesh image to ghcr.io with the
same tags, signing, and supply-chain attestations as the Docker Hub copy.
Same content; pull from whichever registry your environment prefers.
- Adds packages:write to both publish jobs so the auto-provisioned
GITHUB_TOKEN can push to ghcr.io/studio-saelix/sencho and -mesh.
- Adds a second docker/login-action step authenticating to ghcr.io. The
Docker Hub login still resolves credentials from the production env.
- docker/metadata-action now lists both image references; one buildx push
attaches the manifest to both registries in a single round trip.
- Cosign keyless signing already loops over $TAGS, so adding the GHCR
reference is enough to sign both digests.
- The cosign attest step now loops over both registry paths so SBOM (CDX
+ SPDX) and OpenVEX attestations are resolvable from either registry.
Quickstart and image-verification docs note GHCR as an alternative pull
source with identical content.
* fix(mesh-sidecar): pin base image by digest
The main Sencho Dockerfile pins every base image by sha256 digest so a
republish of an upstream tag cannot silently shift content into a
release. The mesh-sidecar Dockerfile pinned node:22-alpine by tag, which
is exactly the gap that pinning closes.
Resolves node:22-alpine to its current multi-arch index digest (covers
linux/amd64 and linux/arm64) and threads it through both build stages
via a single ARG so a future digest roll only edits one line. The inline
comment documents the resolution command.
The sidecar holds an outbound websocket and has no inbound HTTP
listener, so adding a HEALTHCHECK is intentionally out of scope: a
process-liveness probe would be tautological with Docker's restart
policy. The Dockerfile now records that decision so it does not get
reopened on every audit.
* ci(docker): use repository_owner for GHCR login username
github.actor varies by event source (the maintainer who merged the
release PR on tag pushes, github-actions[bot] on bot-driven runs, the
dispatcher on workflow_dispatch). The username field is metadata only;
GITHUB_TOKEN is what authenticates against GHCR. github.repository_owner
resolves to a fixed value (studio-saelix) on every event and matches
GitHub's own example workflows for GHCR push.
* chore(repo): tighten commit subject-case rule
Disabling subject-case entirely allowed accidental ALL-CAPS or
PascalCase subjects through. Restrict to "never upper-case or
pascal-case" instead, which preserves the lowercase / kebab-case
norm of this repo and lets sentence-case subjects (used sparingly
on main) keep passing.
LicenseService.activate() previously stored data.instance?.id || '' on
success. If LS ever returned activated:true without an instance.id
(malformed response, API change, transient bug), the user saw
"Activated successfully" but every subsequent validate() and
deactivate() short-circuited on the empty license_instance_id with a
generic "no active license" error. The activation appeared to succeed
while leaving the install in a broken state.
After the existing catalog-id guard, also require a non-empty
data.instance.id and reject up front with a retry-friendly message if
missing. The check costs nothing on the happy path (LS has historically
always returned instance.id on success) and turns a silent state
divergence into a clear, actionable error.
Adds two tests covering instance object absent and instance.id empty
string. Both assert mockSetSystemState was never called, which catches
any future code that accidentally writes state above the guard.
Adds a block comment above initialize() explaining the dual-name
relationship that confused the original audit: instance_id is the local
UUID we pass to LS as instance_name, license_instance_id is the
LS-issued activation id we pass back as instance_id on validate and
deactivate. Same area, swapped names, no overlap.
release-please-action v5.0.0 (release-please library 17.6.0) returns
exit code 1 when it tries to create a GitHub Release whose tag already
exists, even when the underlying state is consistent (release already
published, manifest current, label state correct). This causes the
action to abort before the "open or update release PR" step, leaving
new conventional commits unprocessed.
Adding workflow_dispatch lets a maintainer re-run the action manually
from the Actions tab once the inconsistent state is cleared (typically
by removing the autorelease label from the prior release PR), without
needing to push a no-op commit to main to retrigger.
The workflow's internal state checks still run on every dispatch, so
manual triggers cannot double-publish a release. release-please-action
is already at latest (v5.0.0, 2026-04-22) so a version bump is not an
option until upstream ships a fix for the duplicate-tag handling.
Lemon Squeezy's /v1/licenses/validate returns valid:true for any license key
in any LS store, so without a hardcoded catalog-identity check, a license
bought for any other LS product unlocks Sencho.
Add a module-level catalog map (store_id, two product_ids, six variant_ids)
and a pure resolveSenchoVariantFromMeta() helper. activate() and validate()
now reject responses whose meta does not match the Sencho catalog before
persisting any state. validate() additionally moves the license_last_validated
write below the catalog guard so a foreign-license refresh cannot extend the
offline grace window. getVariant() now resolves tier from variant_id first
(stable LS catalog identifier) and falls back to the substring match on
variant_name only when no variant_id is present.
Tests cover all 6 valid variants, every rejection branch, both activate and
validate paths, the no-DB-writes invariant on rejection, and the LS-side
key_status=expired/disabled branches.
* feat(blueprints): add backend foundation for fleet-wide compose templates
Introduces the Blueprint Model: a docker-compose.yml plus a node selector
(labels or explicit IDs) that Sencho reconciles across the fleet. Backend
foundation only; the frontend tab and documentation follow.
Schema (DatabaseService):
- node_labels table for fleet-level orchestration tagging
- blueprints table with compose content, selector, drift_mode, classification
- blueprint_deployments table for per-node materialized state
- New idempotent migrate methods following the existing pattern
Services:
- BlueprintAnalyzer: pure compose-YAML classifier (stateless / stateful /
unknown) with 17 covered cases including named volumes, bind mounts,
external volumes, and tmpfs
- NodeLabelService: label CRUD plus selector matching helper (any/all/ids)
- BlueprintService: local + remote deploy/withdraw orchestration, marker
file management, name-conflict guard, per-(blueprint,node) lock
- BlueprintReconciler: 60-second loop with three-mode drift policy
(observe/suggest/enforce), state-aware guards, and Enforce-downgrade for
volume-destroying drift
Routes (gated requirePaid + requireAdmin on mutations):
- /api/blueprints (CRUD + apply + withdraw + accept + preview + analyze)
- /api/node-labels (CRUD + listAll + listDistinct)
Notifications: four new categories registered in NotificationService for
deploy/failure/drift events.
Bootstrap: reconciler start/stop wired in startup and shutdown.
Tests: 45 new Vitest cases covering selector matching, classifier rules,
state-aware guards, drift-mode branching, and marker parsing. Full backend
suite (1625 tests) passes; tsc clean.
* fix(lint): replace bare Function type in blueprint reconciler tests
Replace 8 occurrences of `as unknown as { computeDecision: Function }`
with a properly typed `ReconcilerWithCompute` alias that mirrors the
real method signature. Export `ReconcileDecision` from
BlueprintReconciler so the test can reference it.
Resolves @typescript-eslint/no-unsafe-function-type errors that were
failing the Backend (Lint) CI step.
* feat(fleet): sencho mesh in traffic and routing tab
Lights up Sencho Mesh: cross-node container forwarding rendered as if the
container next to you were on localhost. Builds on the dormant TCP frame
plumbing from the prior PR (pilot tunnel TCP frames + sencho-mesh sidecar
package) and exposes the Admiral-only orchestrator surface.
Backend
- New mesh_stacks table (per-node opt-ins) + nodes.mesh_enabled column
via DatabaseService.migrateMeshTables.
- MeshService singleton: sidecar lifecycle via Dockerode, opt-in/out with
cascading override regeneration, request-based resolver from sidecar
control WS, cross-node TCP forwarding via PilotTunnelManager (same-node
fast path included), in-memory 1000-event activity ring buffer with
durable mirror to audit_log for state-change events, per-node and
per-route diagnostics, and the Test upstream probe.
- MeshComposeOverride: pure YAML generator that injects extra_hosts using
host-gateway. The user's docker-compose.yml is never mutated; overrides
live under DATA_DIR/mesh/overrides.
- ComposeService deploy/update splice the override file when the stack
is opted in; non-mesh stacks behave identically to today.
- Pilot agent resolveMeshTarget consults the local mesh_stacks table
(defense in depth) and resolves Compose containers via Dockerode.
- /api/mesh router with 13 Admiral-gated endpoints covering status,
enable/disable, stack opt-in/out, alias listing, per-route diagnostic,
Test upstream probe, per-node diagnostic, sidecar restart, activity
log paginated and SSE.
- meshControl WS slot at /api/mesh/control validates the mesh_sidecar
JWT minted by MeshService; dispatched as upgrade slot 2 (canonical
order preserved).
Frontend
- New Traffic Routing tab in FleetView, gated by isAdmiral and wrapped
in AdmiralGate. Tab uses the cyan brand glyph and italic-serif state
typography from the audit.
- RoutingTab masthead with mesh activity drawer, per-node card grid
with TogglePill, alias rows with five-state pill taxonomy
(healthy / degraded / unreachable / tunnel-down / not-authorized),
inline Test buttons.
- Four sheets: opt-in picker with port-collision inline error,
per-route detail with diagnostic + filtered activity, per-node
diagnostics with active streams + resolver cache + restart action,
fleet-wide activity log with filters.
- meshRouteState helper centralizes pill-state mapping; pure-function
tests cover all five states.
Docs
- User docs at /docs/features/sencho-mesh.mdx covering opt-in,
troubleshooting, security model (4 guarantees + 4 explicit
non-guarantees), and V1 limitations.
- Internal architecture and runbook pages.
- websocket-dispatch internal doc updated with the new slot.
* fix(mesh): validate stack name before path use; fix test DB lifecycle
Two surgical fixes against the prior PR.
Path-injection (CodeQL js/path-injection): MeshService.optInStack,
optOutStack, ensureStackOverride, and removeStackOverride now validate
stackName via isValidStackName from utils/validation, reject malicious
names at the API boundary, and additionally check isPathWithinBase on
the resolved override file path for defense in depth. The dataflow from
req.params.stackName to fs.writeFile no longer reaches an unsanitized
path expression.
Test DB lifecycle: mesh-service.test.ts used per-test setupTestDb /
cleanupTestDb, which deletes the temp dir while DatabaseService still
holds an open SQLite handle. On Linux CI this raises
SQLITE_READONLY_DBMOVED on the next prepare() because the inode has
been unlinked. Switched to file-scoped beforeAll/afterAll matching
agents-routes.test.ts, with a per-test beforeEach that truncates
mesh_stacks plus non-default nodes and resets the MeshService singleton
in-memory state. Adds a new test case asserting the path-traversal
rejection.
* fix(compose): use discovered compose filename instead of hardcoded docker-compose.yml
composeArgs() hardcoded `-f docker-compose.yml` for every deploy. Sencho
writes its canonical compose file as `compose.yaml`, so any stack created
via the UI failed to deploy with `open ...docker-compose.yml: no such
file or directory`.
When no mesh override applies, drop the explicit `-f` so docker compose's
built-in discovery resolves the actual filename. When an override exists,
look up the real base filename via FileSystemService.getComposeFilename()
and pass both files explicitly.
Also hoist the MeshService import to module top now that the dependency
is known to be acyclic, and revert the matching unit-test assertion.
Lays the dormant data-plane foundation for Sencho Mesh. The pilot tunnel
gains TCP forwarding frames (tcp_open / tcp_open_ack / tcp_close JSON
plus a 0x04 TcpData binary type) and a TcpStream surface on the bridge
so a future MeshService can ride the existing WSS tunnel for cross-node
container traffic. The agent rejects every tcp_open with mesh_not_enabled
until a follow-up PR wires the Dockerode resolver gated by a
mesh_stacks opt-in table; ships dormant.
A new top-level mesh-sidecar/ package provides the per-node container
that will host the L4 forwarder + control WS in production. Built as a
small Node 22 alpine image and published in lockstep with the main
sencho image via a parallel docker-publish workflow job.
Tests cover protocol roundtrips on both packages and the sidecar
forwarder end-to-end including resolve, splice, close, and stats.
Reserves three navigable but non-functional tab slots on the Fleet page so
each future orchestrator surface can land as a tab content swap rather than
a navigation redesign. Each tab opens a coming-soon placeholder card listing
the planned actions for that surface.
* feat(editor): add useComposeDiffPreviewEnabled hook
* feat(editor): add ComposeDiffPreviewDialog component
* fix(editor): replace HTML entity with Unicode arrow in ComposeDiffPreviewDialog
* feat(editor): add diff preview toggle to Appearance settings
Added a new 'Diff preview before save' toggle in the Display section of the Appearance settings panel. Users can now enable or disable the side-by-side diff view before compose and env file edits are saved to disk.
* feat(editor): wire diff preview dialog into compose save flow
* fix(editor): snapshot diff content at open time and fix event name
- Fix useComposeDiffPreviewEnabled and useDeployFeedbackEnabled to use
the canonical SENCHO_SETTINGS_CHANGED constant from @/lib/events
instead of the hardcoded string literal (wrong value)
- Snapshot language, original, modified, and fileName into diffPreview
state at click time to prevent tab-switching from corrupting dialog
content mid-review
- Remove React.MouseEvent from diffPreview state; pass a no-op stub to
deployStack in the confirm path (preventDefault/stopPropagation are
no-ops on an already-settled event anyway)
- Add diff-modal screenshot and document the feature in editor.mdx and
settings.mdx
* docs(editor): add settings-toggle screenshot for diff preview feature
* feat(stack): per-stack activity timeline with actor attribution
Adds an Activity tab to the Stack Anatomy panel showing a timestamped
event log for each stack: deploys, restarts, starts, stops, and image
updates, attributed to the user who triggered them or 'system' for
automated actions.
Backend:
- Extends notification_history with actor_username column (idempotent
migration) and a partial composite index on (node_id, stack_name,
timestamp DESC) for efficient per-stack lookups.
- NotificationService.dispatchAlert() accepts an optional actor that
is written to the new column.
- Success-side dispatchAlert calls added after deploy, bulkContainerOp
(start/stop/restart), and update handlers in routes/stacks.ts so
user-initiated operations are recorded, not just failures.
- New GET /api/stacks/:stackName/activity?limit&before endpoint with
stack:read permission gate and cursor-based pagination.
Frontend:
- StackAnatomyPanel grows an Anatomy / Activity tab pair using the
existing Tabs primitive.
- StackActivityTimeline fetches the initial 50 events, paginates on
demand, and prepends live events arriving over the existing WS
notifications stream without duplicates.
- NotificationPanel bell dropdown suppresses user-initiated success
events (start/stop/restart/deploy/update triggered by a real user),
keeping the tray focused on alerts and system events.
* docs(stack): add stack activity timeline feature page and internal arch docs
* fix(test): add actor_username to notification-routing history assertions
dispatchAlert now passes actor_username to addNotificationHistory after
the activity timeline PR added the column. Update the two exact-match
assertions that were failing because the expected object shape was missing
this field.
* feat(sidebar): bulk stack operations (select, start/stop/restart/update)
- Add ⊞ bulk mode toggle in SidebarActions (cyan active state, tooltip "Bulk
mode (B)"); keyboard shortcut B toggles, Esc exits, Ctrl+A selects all
visible (chip-filtered) stacks
- Reserved checkbox column in StackRow becomes visible and interactive in bulk
mode; clicking a row in bulk mode toggles selection instead of opening the
stack; kebab and context-menu still work in either mode
- SidebarBulkBar appears below filter chips when >=1 stack selected: shows
count, Start / Stop / Restart / Update actions; Update is disabled with a
Skipper TierBadge for Community licenses
- useBulkStackActions hook fans out operations via Promise.allSettled and
surfaces an aggregate toast ("3 of 4 restarted; 1 failed: plex")
- Bulk update enforced Skipper-gated frontend-side (isPaid check in hook) and
sends x-bulk-mode header for backend defense-in-depth
- Extract isInputFocused / isPaletteOpen to lib/keyboard-guards.ts; both
useStackKeyboardShortcuts and the new bulk keyboard effect now share the
same guards instead of duplicating the logic
- chipFilteredFiles captured via useRef in bulk keyboard effect so the listener
is not torn down and re-added on every status-poll cycle
* fix(sidebar): separate TooltipProviders for bulk and scan icon buttons
Wrapping both icon buttons in a single TooltipProvider made them
render as one flex child, collapsing the gap-2 between them.
Splitting into two independent TooltipProviders restores the 8px
gap and right padding of the scan button.
* feat(sidebar): §14 sidebar orchestration (filter chips, pinned rail, trailing column)
- Add All / Up / Down / Updates filter chips with live counts; active chip
filters the list; chip-filtered files computed in EditorLayout with useMemo
- Surface the PINNED group with a 3px cyan left rail and glow via the
sidebarPinnedGroupRail token; reuses the brand token already on the active row
- Compact brand row from three stacked elements to a single 44px horizontal bar
- Restructure StackRow trailing column as fixed slots: label dots (max 3 + +N
overflow), update-dot | git-pending icon (priority order), kebab; add reserved
invisible checkbox slot for PR2 bulk mode
- Export statusText / statusColor from StackRow and reuse them in StackList
remote-results section to remove the duplicate inline logic
- Lift filterChip state and chip-filtered files to EditorLayout; remove
filterChip from StackListProps to eliminate the dual-path redundancy
- Remove unused labels param from buildGroups and the void labels workaround
- Wrap filteredFiles in useMemo so filterCounts memo is not defeated on every
render
* fix(sidebar): move statusText and statusColor to stack-status-utils
react-refresh/only-export-components requires component files to export
only components. Move the two utility functions and StackRowStatus type
to a dedicated stack-status-utils.ts so StackRow.tsx is a pure component
module. Update StackList.tsx and EditorLayout.tsx to import from the new
source directly.
* feat(settings): dress the page to match the audit (cyan rail, italic serif, two-column rows)
Brings the full-page Settings route into the Sencho voice. The page now
opens with a full-width PageMasthead (cyan rail, mono crumb, italic
serif title, contextual stat strip) above a sidebar and main-content
panel, each as a rounded-xl card inset on the dark background.
Sidebar drops the duplicate "Settings" header and the candy tier badges.
Group headers carry mono labels with visible/total counts; gated rows
get a neutral uppercase lock chip and dim. Active rows keep the cyan
2px rail.
Five new primitives (SettingsSection, SettingsField, SettingsCallout,
SettingsActions / SettingsPrimaryButton, TierLockChip) replace the
stacked label-input-help shadcn defaults and the per-section ad-hoc
chrome. AccountSection, AppearanceSection, LicenseSection, SystemSection,
NotificationsSection, DeveloperSection, AppStoreSection, AboutSection,
and SupportSection are migrated to the new layout. The list-driven
sections (Webhooks, Routing, Users, Labels, Security, CloudBackup,
ApiTokens, Registries, NodeManager, SSO) keep their list cards but get
the new chrome and primary CTAs.
Each section can publish contextual stats to the masthead via a small
context channel: 2FA state on Account, plan/trial/renews on License,
edited count on System, channel counts on Notifications, etc.
* refactor(settings): drop react-router-dom and align with DESIGN.md
The Settings page was the only surface using react-router-dom for sub-section
navigation. Every other primary view (Home, Fleet, Resources, App Store,
Schedules, etc.) drives view switching through a single activeView useState in
EditorLayout. This change removes the dependency end-to-end:
- App.tsx drops BrowserRouter
- EditorLayout adds 'settings' to the activeView union; SettingsPage renders
inside the same flex-1 overflow-y-auto p-6 wrapper as siblings
- UserProfileDropdown receives an onOpenSettings callback instead of
useNavigate. SettingsPage owns currentSection via props lifted to
EditorLayout, so cross-component navigation (openLabelManager,
onManageNodes, ConfigurationStatus rows) can route to a sub-section
- SettingsSidebar items become buttons (no more NavLink); SectionGate's
redirect-on-invisible falls back through SettingsPage's safeSection memo
- e2e/nodes.spec.ts updates the Nodes selector from link to button role
- react-router-dom removed from package.json + package-lock.json
The visual treatment is brought into alignment with frontend/DESIGN.md,
which was rewritten this week to be the normative extract of the audit:
- PageMasthead: title text-3xl → text-[22px] Section rung italic; kicker
11px → 10px Label rung; stat label tracking 0.22em → 0.18em; stat value
font-medium for mono Stat-rung family discipline
- SettingsField helper: mono → sans Body rung 14/22; success tone now uses
--success green (was incorrectly mapped to brand cyan)
- SettingsCallout: title tracking 0.18em; subtitle Body rung 14px; success
tone now genuinely uses --success green; new brand tone for promotional
callouts (Trial CTA, Admiral upgrade) that should read cyan
- SettingsActions: SettingsPrimaryButton renders mono uppercase tracked,
size sm by default. DESIGN §9.10 requires "small mono uppercase, cyan-
filled" for every Settings primary CTA
- TierLockChip: 9px → 10px Label rung floor
- SettingsSidebar: group header tracking 0.18em; ⌘K kbd 9px → 10px;
aside gains text-card-foreground transition-colors per §10 canonical
card class
- SettingsPage main panel: text-card-foreground transition-colors added;
uses h-full overflow-auto p-6 to mirror FleetView's wrapper rhythm
- Field rows, section headers, action rows now consume var(--density-*)
tokens with literal fallbacks so Settings respects the comfortable/
compact toggle
* fix(e2e): update mfa openAccountSettings to match settings redesign
Settings now opens to the Account section by default when accessed from
the profile dropdown, and the Account section no longer renders an h2
heading element. Update the openAccountSettings helper to open the
correct section and assert on the Password h3 heading that SettingsSection
renders instead.
* test(e2e): fix MFA enrolment assertion after settings redesign
The 2FA enrolment badge was replaced with a kicker/field pattern.
Assert on the 'enrolled' text that the new design renders instead of
the removed Enabled badge.
* test(e2e): fix low-backup-codes warning assertions after settings redesign
Update two assertions in the 'low backup codes warning' test that
referenced UI text removed in the settings redesign:
- '1 backup code remaining' -> '1 remaining' (SettingsField body text)
- 'Regenerate now' button -> callout subtitle text, which uniquely
identifies the zero-codes error card without hitting strict-mode
from two identically-labelled Regenerate buttons on the page
* test(e2e): navigate to root before re-opening settings for mock refresh
The settings redesign uses a nested full-page route. Navigating to the
same URL a second time does not remount the component, so AccountSection
retains cached MFA state and the 0-codes branch never fetches. A
page.goto('/') ensures full unmount before the second openAccountSettings
call, so the refreshed mock is actually hit.
* test(e2e): scroll zero-codes callout into view before asserting visibility
The callout sits below the Disable 2FA section in the MFA settings page
and is scrolled out of the clipped content area on initial render.
scrollIntoViewIfNeeded() brings it into the visible viewport before the
toBeVisible assertion.
* test(e2e): scroll Radix ScrollArea viewport for zero-codes callout assertion
The settings page wraps content in a Radix ScrollArea whose Root has
overflow:hidden, so the browser's native scrollIntoView cannot scroll
the inner viewport. Wait for the callout to attach (confirms mock data
loaded), then programmatically set scrollTop on the Radix viewport
element before asserting visibility.
* test(e2e): use toBeAttached for zero-codes callout to avoid Radix clip issue
The callout renders below the Disable 2FA section, outside the visible
clip area of the Radix ScrollArea Root (overflow:hidden) on a standard
viewport. Playwright's visibility check uses the clip intersection, so
toBeVisible() fails even after programmatic scroll. toBeAttached()
confirms the component rendered the warning card for backupCodesRemaining:0
without depending on the element's scroll position.
* refactor(settings): replace modal with nested full-page route
Settings sections are now URL-addressable at /settings/:sectionId, rendered
nested inside EditorLayout alongside the stack sidebar. Browser back/forward
navigates between sections. Deep links (e.g. /settings/cloud-backup) load
the section directly on hard reload.
- Add react-router-dom v7; BrowserRouter wraps the full app tree
- New SettingsPage (scroll memory, Cmd+K palette), SettingsSidebar (NavLink
active styling, back-arrow), SectionGate (visibility + tier lock card)
- Rename SectionId 'appstore' to 'app-store' so slug === SectionId
- Decouple SystemSection, DeveloperSection, AppStoreSection from modal-
passed props; each fetches its own data on mount
- Replace onLabelsChanged prop chain with SENCHO_LABELS_CHANGED window event
- Drop onOpenSettings prop from UserProfileDropdown, HomeDashboard,
ConfigurationStatus; each calls useNavigate directly
- Delete SettingsModal.tsx
* fix(settings): validate sectionId against registry before property write
Prevents prototype pollution (CodeQL js/remote-property-injection #243).
URL param sectionId is checked against SETTINGS_ITEMS before being used
as a property key on scrollPositionsRef.
* fix(settings): eliminate remote property injection via Map and registry-sourced key
Two-part fix for CodeQL js/remote-property-injection:
1. currentSection is now derived from SETTINGS_ITEMS.find().id (trusted
registry data) instead of the raw sectionId URL param. The tainted
string never flows into any property access.
2. scrollPositionsRef uses Map<SectionId, number> with .get()/.set()
instead of a plain object. Map operations do not write to the prototype
chain, removing the prototype pollution vector entirely.
* test(e2e): align settings selectors with full-page route
The settings refactor (4475afd) replaced the modal with a nested route.
The new sidebar renders sub-sections as NavLinks (role link, not button)
and adds a "Filter settings" button that collides with the loose
/settings/i regex used in mfa and nodes specs.
- Use exact 'Settings' match for the profile-dropdown menu row
- Switch the Nodes sub-section selector from button to link role
README: rewrite to lead with differentiation rather than a flat
feature list. Add "Why Sencho?" section covering the four key
competitive advantages (Pilot Agent NAT traversal, auto-heal
self-healing, atomic deployments, automation-first model). Expand
feature coverage from 8 bullets to six grouped domains reflecting
the full 38-feature surface. Add architecture note, docker run
one-liner, tier comparison table (Community/Skipper/Admiral), and
docs link section. Drop stale CI badge; add Docker Pulls badge.
CONTRIBUTING: add project layout reference, tier-gate usage guide,
TypeScript strictness reminder, and pointer to CLAUDE.md for full
coding standards.
SECURITY: update supported versions table from stale "0.2.x+" to
current "latest release" policy with a note on self-hosted update
cadence.