Compare commits

..
Author SHA1 Message Date
EthanHealy01 600add1474 run frontend fix 2026-05-27 12:42:22 +01:00
EthanHealy01 8451b06d53 chore: set aiEngine env vars automatically in dev:all
When AIENGINE_URL is provided (i.e. task dev:all), also set
AIENGINE_ENABLED=true and AIENGINE_TIMEOUTSECONDS=120 so the backend
picks up the full AI engine config without needing it in settings.yml.
2026-05-27 12:12:42 +01:00
EthanHealy01 7bb3024dc9 feat: add midnight theme and fix theme locations
Adds midnight dark theme, fixes broken theme variable locations, adds
clean-unused-theme-vars script, and updates RainbowThemeProvider and
related theme constants.
2026-05-27 11:37:49 +01:00
EthanHealy01 18cd2baec0 feat: add Agents UI to proprietary right sidebar
Adds agents panel to the right sidebar, gated behind aiEngineEnabled.
Includes RightSidebar refactor, FullscreenToolPanel, core stubs, and
proprietary AgentsPanel with Stirling hero CTA and coming-soon agents.
2026-05-27 11:14:19 +01:00
100 changed files with 5388 additions and 3555 deletions
+3 -2
View File
@@ -157,7 +157,9 @@ app/proprietary/build
common/build
proprietary/build
stirling-pdf/build
frontend/editor/src-tauri/provisioner/target
frontend/src-tauri/provisioner/target
frontend/src-tauri/target
frontend/src-tauri/runtime/
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -174,7 +176,6 @@ venv.bak/
# Env files (secrets / local overrides). Subproject .gitignore files whitelist any committed defaults.
.env*
!.env.saas.example
# VS Code
/.vscode/**/*
+2 -2
View File
@@ -25,9 +25,9 @@ tasks:
env:
SERVER_PORT: '{{.PORT}}'
cmds:
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS=120 {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
platforms: [windows]
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true {{end}}./gradlew :stirling-pdf:bootRun'
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED=true AIENGINE_TIMEOUTSECONDS=120 {{end}}./gradlew :stirling-pdf:bootRun'
platforms: [linux, darwin]
dev:bundled:
+15
View File
@@ -257,6 +257,7 @@ tasks:
- task: lint
- task: format:check
- task: test
- task: theme:check-unused-vars
check:all:
desc: "Full CI quality gate"
@@ -298,3 +299,17 @@ tasks:
deps: [install]
cmds:
- node editor/scripts/generate-licenses.js
theme:clean-unused-vars:
desc: "Remove unused CSS variables from theme.css"
deps: [install]
vars:
REMOVE: '{{.REMOVE | default "false"}}'
cmds:
- node editor/scripts/clean-unused-theme-vars.js{{if eq .REMOVE "true"}} --remove{{end}} {{.CLI_ARGS}}
theme:check-unused-vars:
desc: "Fail if theme.css contains unused CSS variables"
deps: [install]
cmds:
- node editor/scripts/clean-unused-theme-vars.js --check
+1 -1
View File
@@ -74,7 +74,7 @@ tasks:
vars:
PORT: '{{.BACKEND_PORT}}'
AIENGINE_URL: 'http://localhost:{{.ENGINE_PORT}}'
- task: frontend:dev:prototypes
- task: frontend:dev
vars:
PORT: '{{.FRONTEND_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
-3
View File
@@ -78,9 +78,6 @@ dependencies {
runtimeOnly "com.stirling:jpdfium-natives-${platform}:1.0.1"
}
// Bucket4j (local in-process token bucket for RateLimitStore default impl)
implementation 'com.bucket4j:bucket4j_jdk17-core:8.19.0'
// ArchUnit: enforces module dependency direction (see ArchitectureTest)
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
}
@@ -1,24 +0,0 @@
package stirling.software.common.cluster;
/** Health and identity facade for the active cluster backplane. */
public interface ClusterBackplane {
/** Returns {@code true} when the backplane is reachable; used for health endpoints. */
boolean isHealthy();
/** Returns {@code "inprocess"} or {@code "valkey"}. */
String backplaneType();
/** Returns this JVM's stable node id (matches {@code Cluster.resolvedNodeId()}). */
String localNodeId();
/**
* Whether this JVM should run the local {@link
* stirling.software.common.service.TaskManager#cleanupOldJobs()} loop. Distributed backplanes
* own job expiry via their own TTL, so they should override this to return {@code false}.
* Defaults to {@code true} so in-process behavior is preserved without an explicit override.
*/
default boolean shouldRunLocalCleanup() {
return true;
}
}
@@ -1,63 +0,0 @@
package stirling.software.common.cluster;
import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Cluster;
/**
* Validates that cluster mode is internally consistent.
*
* <p>Cluster settings are bound on the central {@link ApplicationProperties} under {@code
* cluster.*}; this class reads {@link ApplicationProperties#getCluster()} and runs guards in {@link
* PostConstruct}. When {@code cluster.enabled=false} (the default) all checks are skipped so a
* single-instance install needs no new config.
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class ClusterConfig {
private final ApplicationProperties applicationProperties;
@PostConstruct
void validate() {
Cluster cluster = applicationProperties.getCluster();
if (!cluster.isEnabled()) {
return;
}
String backplane = cluster.getBackplane();
if ("valkey".equalsIgnoreCase(backplane)) {
String url = cluster.getValkey() == null ? null : cluster.getValkey().getUrl();
if (url == null || url.isBlank()) {
throw new IllegalStateException(
"cluster.enabled=true with backplane=valkey requires"
+ " cluster.valkey.url to be set (e.g."
+ " redis://valkey:6379).");
}
} else if ("inprocess".equalsIgnoreCase(backplane)) {
// enabled+inprocess only coordinates the local JVM; cross-node lookups will 410.
log.warn(
"cluster.enabled=true with backplane=inprocess - only the local"
+ " JVM is coordinated. Cross-node lookups and the file proxy will fail."
+ " Use backplane=valkey for real multi-node deployments.");
} else {
// Fail fast on typos like "valky" so Spring doesn't later report a cryptic
// "no ClusterBackplane bean" - the operator-facing error names the bad value.
throw new IllegalStateException(
"cluster.enabled=true with unknown backplane '"
+ backplane
+ "'. Valid values: inprocess | valkey.");
}
log.info(
"Cluster mode enabled (backplane={}, nodeRole={}, nodeId={}).",
backplane,
cluster.resolvedRole(),
cluster.resolvedNodeId());
}
}
@@ -1,12 +0,0 @@
package stirling.software.common.cluster;
import java.time.Instant;
/**
* Snapshot of a peer node as recorded in the {@link InstanceRegistry}.
*
* @param internalAddress {@code host:port} the node listens on for {@code /internal/cluster/**}
* @param role one of {@code WEB}, {@code WORKER}, {@code BOTH}
*/
public record ClusterNode(
String nodeId, String internalAddress, Instant lastHeartbeat, String role) {}
@@ -1,21 +0,0 @@
package stirling.software.common.cluster;
import java.time.Duration;
import java.util.Optional;
/** Cluster-wide mutual exclusion primitive; non-reentrant by contract. */
public interface DistributedLock {
Optional<LockHandle> tryAcquire(String lockKey, Duration leaseTime);
interface LockHandle extends AutoCloseable {
void release();
boolean renew(Duration leaseTime);
@Override
default void close() {
release();
}
}
}
@@ -1,45 +0,0 @@
package stirling.software.common.cluster;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
/** Low-level storage seam for result/job files. */
public interface FileStore {
/** Stored file record. */
record Stored(String fileId, long size) {}
/** Store the given stream and return a generated file id and total bytes written. */
Stored store(InputStream in, String originalName) throws IOException;
/**
* Store the file at {@code source} and return a generated file id and total bytes written.
*
* <p>Default implementation opens {@code source} as a stream and delegates to {@link
* #store(InputStream, String)}. Local-disk implementations should override to use a direct
* file-to-file copy ({@code Files.copy(source, dest)} can use {@code sendfile(2)} on Linux),
* which avoids the two-memory-copy hit of streaming a disk-backed upload through the JVM heap.
*/
default Stored store(Path source, String originalName) throws IOException {
try (InputStream in = Files.newInputStream(source)) {
return store(in, originalName);
}
}
/** Open the stored file for streaming reads. Caller closes. */
InputStream retrieve(String fileId) throws IOException;
/** Load the stored file into a byte array. */
byte[] retrieveBytes(String fileId) throws IOException;
/** Size of the stored file in bytes. */
long size(String fileId) throws IOException;
/** Delete the stored file. Returns true if a file was removed. */
boolean delete(String fileId);
/** Whether the file id exists in the store. */
boolean exists(String fileId);
}
@@ -1,18 +0,0 @@
package stirling.software.common.cluster;
import java.time.Duration;
import java.util.Collection;
import java.util.Optional;
/** Maps {@code nodeId} to its internal cluster address, with TTL'd heartbeats. */
public interface InstanceRegistry {
/** Register or refresh this node. Idempotent so a wiped backplane self-heals on next tick. */
void register(ClusterNode node, Duration heartbeatTtl);
Optional<ClusterNode> lookup(String nodeId);
Collection<ClusterNode> activeNodes();
void deregister(String nodeId);
}
@@ -1,24 +0,0 @@
package stirling.software.common.cluster;
import java.time.Duration;
import java.util.Collection;
import java.util.Optional;
/** Cluster-visible storage for job status and result metadata, with TTL'd entries. */
public interface JobStore {
/** Persist or overwrite a job entry. {@code ttl} sets the lifetime of the entry. */
void put(JobStoreEntry entry, Duration ttl);
Optional<JobStoreEntry> get(String jobId);
void delete(String jobId);
boolean exists(String jobId);
/** Reverse lookup: which job owns this result file id? */
Optional<String> findJobIdByFileId(String fileId);
/** Snapshot of every active entry. Used by admin/stats endpoints; may be O(n). */
Collection<JobStoreEntry> all();
}
@@ -1,30 +0,0 @@
package stirling.software.common.cluster;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/**
* Cluster-visible projection of a job's status and result metadata, as persisted in {@link
* JobStore}.
*
* @param owningNodeId the node id that originally executed the job
*/
public record JobStoreEntry(
String jobId,
JobState state,
String owningNodeId,
Instant createdAt,
Instant completedAt,
String error,
List<String> fileIds,
Map<String, String> resultMeta) {
/** Lifecycle states for a job as observed by the cluster. */
public enum JobState {
PENDING,
RUNNING,
COMPLETE,
FAILED
}
}
@@ -1,16 +0,0 @@
package stirling.software.common.cluster;
import java.time.Duration;
import java.util.Optional;
/** Short-TTL namespaced key/value cache backed by the cluster backplane. */
public interface KeyValueCache {
void put(String namespace, String key, String value, Duration ttl);
Optional<String> get(String namespace, String key);
void evict(String namespace, String key);
void evictNamespace(String namespace);
}
@@ -1,18 +0,0 @@
package stirling.software.common.cluster;
import java.time.Duration;
/** Token-bucket rate limiting backed by the cluster backplane. */
public interface RateLimitStore {
/**
* Attempt to consume one token from the bucket identified by {@code bucketKey}.
*
* @param bucketKey opaque key identifying the bucket (e.g. {@code api:user:123})
* @param capacity bucket capacity
* @param refillPeriod time window over which {@code capacity} tokens refill
*/
RateLimitDecision tryConsume(String bucketKey, long capacity, Duration refillPeriod);
record RateLimitDecision(boolean allowed, long remainingTokens, long nanosToWaitForRefill) {}
}
@@ -1,7 +0,0 @@
package stirling.software.common.cluster;
/** Records one increment per sticky-session miss (a 410 Gone for a job owned by another node). */
@FunctionalInterface
public interface StickyMissRecorder {
void recordStickyMiss();
}
@@ -1,31 +0,0 @@
package stirling.software.common.cluster.inprocess;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.model.ApplicationProperties;
@Slf4j
public class InProcessClusterBackplane implements ClusterBackplane {
private final ApplicationProperties applicationProperties;
public InProcessClusterBackplane(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
@Override
public boolean isHealthy() {
return true;
}
@Override
public String backplaneType() {
return "inprocess";
}
@Override
public String localNodeId() {
return applicationProperties.getCluster().resolvedNodeId();
}
}
@@ -1,65 +0,0 @@
package stirling.software.common.cluster.inprocess;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.cluster.DistributedLock;
import stirling.software.common.cluster.InstanceRegistry;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.cluster.KeyValueCache;
import stirling.software.common.cluster.RateLimitStore;
import stirling.software.common.model.ApplicationProperties;
/**
* Default cluster backplane wiring: every interface gets an {@code InProcess*} bean. Active when
* cluster mode is off or {@code cluster.backplane=inprocess}.
*/
@Slf4j
@Configuration
@ConditionalOnExpression(
"!${cluster.enabled:false} ||"
+ " '${cluster.backplane:inprocess}'.equalsIgnoreCase('inprocess')")
public class InProcessClusterConfiguration {
@Bean
@ConditionalOnMissingBean
public ClusterBackplane clusterBackplane(ApplicationProperties applicationProperties) {
log.info("Cluster backplane: in-process (single node)");
return new InProcessClusterBackplane(applicationProperties);
}
@Bean
@ConditionalOnMissingBean
public JobStore jobStore() {
return new InProcessJobStore();
}
@Bean
@ConditionalOnMissingBean
public RateLimitStore rateLimitStore() {
return new InProcessRateLimitStore();
}
@Bean
@ConditionalOnMissingBean
public DistributedLock distributedLock() {
return new InProcessDistributedLock();
}
@Bean
@ConditionalOnMissingBean
public KeyValueCache keyValueCache() {
return new InProcessKeyValueCache();
}
@Bean
@ConditionalOnMissingBean
public InstanceRegistry instanceRegistry() {
return new InProcessInstanceRegistry();
}
}
@@ -1,128 +0,0 @@
package stirling.software.common.cluster.inprocess;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import stirling.software.common.cluster.DistributedLock;
/**
* In-process {@link DistributedLock}, non-reentrant per the interface contract, with lease-expiry
* semantics that mirror a SET-NX-EX style distributed backend.
*
* <p>Each lock state carries a per-acquire {@code ownerToken} and an {@code expiryNanos}; another
* caller can take over once the lease has elapsed even if the original holder never called {@link
* LockHandle#release()}. This matters mostly for parity with the Valkey-backed implementation
* (Redis {@code SETEX} auto-expires the key); within a single JVM a crashed holder takes its lock
* state with it, but tests and code that rely on the {@code leaseTime} parameter still need it to
* be honored.
*
* <p>Expiry is lazy: an expired lock state lingers in the map until the next acquire attempt for
* the same key replaces it. Per-key cleanup also happens on explicit {@link LockHandle#release()},
* so a balanced acquire/release workload keeps the map size bounded.
*/
public class InProcessDistributedLock implements DistributedLock {
private final ConcurrentHashMap<String, LockState> locks = new ConcurrentHashMap<>();
private final AtomicLong tokenSeq = new AtomicLong();
/**
* Lease state for a single acquired lock. {@code ownerToken} prevents a former holder from
* releasing or renewing a lock now owned by someone else after lease expiry; {@code
* expiryNanos} is read/written only inside {@link ConcurrentHashMap#compute} so the bin lock
* provides the necessary happens-before guarantee.
*/
private static final class LockState {
final long ownerToken;
long expiryNanos;
LockState(long ownerToken, long expiryNanos) {
this.ownerToken = ownerToken;
this.expiryNanos = expiryNanos;
}
}
@Override
public Optional<LockHandle> tryAcquire(String lockKey, Duration leaseTime) {
long token = tokenSeq.incrementAndGet();
long nowNanos = System.nanoTime();
long expiryNanos = nowNanos + leaseTime.toNanos();
boolean[] acquired = {false};
locks.compute(
lockKey,
(k, existing) -> {
if (existing == null || existing.expiryNanos - nowNanos <= 0L) {
// No lock, or the previous lease has expired - we take it. Subtraction
// form avoids the long-overflow trap that would bite a naive
// expiryNanos <= nowNanos comparison around System.nanoTime() rollover.
acquired[0] = true;
return new LockState(token, expiryNanos);
}
return existing;
});
if (!acquired[0]) {
return Optional.empty();
}
return Optional.of(new InProcessHandle(lockKey, token));
}
private void releaseInternal(String lockKey, long token) {
locks.compute(
lockKey,
(k, existing) -> {
if (existing == null || existing.ownerToken != token) {
// Already removed, expired-and-replaced, or never ours.
return existing;
}
return null;
});
}
private boolean renewInternal(String lockKey, long token, Duration leaseTime) {
long nowNanos = System.nanoTime();
boolean[] renewed = {false};
locks.compute(
lockKey,
(k, existing) -> {
if (existing == null
|| existing.ownerToken != token
|| existing.expiryNanos - nowNanos <= 0L) {
// Lock is gone or expired; renewal is a no-op so the caller can detect it.
return existing;
}
existing.expiryNanos = nowNanos + leaseTime.toNanos();
renewed[0] = true;
return existing;
});
return renewed[0];
}
private final class InProcessHandle implements LockHandle {
private final String lockKey;
private final long token;
private boolean released;
InProcessHandle(String lockKey, long token) {
this.lockKey = lockKey;
this.token = token;
}
@Override
public synchronized void release() {
if (released) {
return;
}
released = true;
releaseInternal(lockKey, token);
}
@Override
public synchronized boolean renew(Duration leaseTime) {
if (released) {
return false;
}
return renewInternal(lockKey, token, leaseTime);
}
}
}
@@ -1,42 +0,0 @@
package stirling.software.common.cluster.inprocess;
import java.time.Duration;
import java.util.Collection;
import java.util.Collections;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicReference;
import stirling.software.common.cluster.ClusterNode;
import stirling.software.common.cluster.InstanceRegistry;
public class InProcessInstanceRegistry implements InstanceRegistry {
private final AtomicReference<ClusterNode> self = new AtomicReference<>();
@Override
public void register(ClusterNode node, Duration heartbeatTtl) {
self.set(node);
}
@Override
public Optional<ClusterNode> lookup(String nodeId) {
ClusterNode current = self.get();
return current != null && current.nodeId().equals(nodeId)
? Optional.of(current)
: Optional.empty();
}
@Override
public Collection<ClusterNode> activeNodes() {
ClusterNode current = self.get();
return current == null ? Collections.emptyList() : Collections.singletonList(current);
}
@Override
public void deregister(String nodeId) {
ClusterNode current = self.get();
if (current != null && current.nodeId().equals(nodeId)) {
self.set(null);
}
}
}
@@ -1,96 +0,0 @@
package stirling.software.common.cluster.inprocess;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.cluster.JobStoreEntry;
@Slf4j
public class InProcessJobStore implements JobStore {
private final ConcurrentHashMap<String, Holder> entries = new ConcurrentHashMap<>();
@Override
public void put(JobStoreEntry entry, Duration ttl) {
Instant expiry = ttl == null ? Instant.MAX : Instant.now().plus(ttl);
entries.put(entry.jobId(), new Holder(entry, expiry));
}
@Override
public Optional<JobStoreEntry> get(String jobId) {
Holder h = entries.get(jobId);
if (h == null) {
return Optional.empty();
}
if (h.isExpired()) {
entries.remove(jobId, h);
return Optional.empty();
}
return Optional.of(h.entry);
}
@Override
public void delete(String jobId) {
entries.remove(jobId);
}
@Override
public boolean exists(String jobId) {
return get(jobId).isPresent();
}
@Override
public Optional<String> findJobIdByFileId(String fileId) {
for (Map.Entry<String, Holder> e : entries.entrySet()) {
Holder h = e.getValue();
if (h.isExpired()) {
continue;
}
List<String> fileIds = h.entry.fileIds();
if (fileIds != null && fileIds.contains(fileId)) {
return Optional.of(e.getKey());
}
}
return Optional.empty();
}
@Override
public Collection<JobStoreEntry> all() {
List<JobStoreEntry> result = new ArrayList<>(entries.size());
for (Holder h : entries.values()) {
if (!h.isExpired()) {
result.add(h.entry);
}
}
return result;
}
/** Drop entries whose TTL has elapsed. Called by the {@code TaskManager} cleanup scheduler. */
public int purgeExpired() {
int removed = 0;
Instant now = Instant.now();
for (Map.Entry<String, Holder> e : entries.entrySet()) {
if (!e.getValue().expiry.equals(Instant.MAX) && e.getValue().expiry.isBefore(now)) {
if (entries.remove(e.getKey(), e.getValue())) {
removed++;
}
}
}
return removed;
}
private record Holder(JobStoreEntry entry, Instant expiry) {
boolean isExpired() {
return !expiry.equals(Instant.MAX) && expiry.isBefore(Instant.now());
}
}
}
@@ -1,55 +0,0 @@
package stirling.software.common.cluster.inprocess;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import stirling.software.common.cluster.KeyValueCache;
public class InProcessKeyValueCache implements KeyValueCache {
private final ConcurrentHashMap<String, ConcurrentHashMap<String, Expiring>> namespaces =
new ConcurrentHashMap<>();
@Override
public void put(String namespace, String key, String value, Duration ttl) {
Instant expiry = ttl == null ? Instant.MAX : Instant.now().plus(ttl);
namespaces
.computeIfAbsent(namespace, n -> new ConcurrentHashMap<>())
.put(key, new Expiring(value, expiry));
}
@Override
public Optional<String> get(String namespace, String key) {
Map<String, Expiring> ns = namespaces.get(namespace);
if (ns == null) {
return Optional.empty();
}
Expiring e = ns.get(key);
if (e == null) {
return Optional.empty();
}
if (e.expiry.isBefore(Instant.now())) {
ns.remove(key, e);
return Optional.empty();
}
return Optional.of(e.value);
}
@Override
public void evict(String namespace, String key) {
Map<String, Expiring> ns = namespaces.get(namespace);
if (ns != null) {
ns.remove(key);
}
}
@Override
public void evictNamespace(String namespace) {
namespaces.remove(namespace);
}
private record Expiring(String value, Instant expiry) {}
}
@@ -1,49 +0,0 @@
package stirling.software.common.cluster.inprocess;
import java.time.Duration;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.ConsumptionProbe;
import io.github.bucket4j.local.LocalBucketBuilder;
import stirling.software.common.cluster.RateLimitStore;
/** Bucket4j-backed token bucket implementation of {@link RateLimitStore}. */
public class InProcessRateLimitStore implements RateLimitStore {
/** Cap to bound memory; oldest accessed buckets are evicted. */
private static final int MAX_BUCKETS = 10_000;
private final Map<String, Bucket> buckets =
Collections.synchronizedMap(
new LinkedHashMap<String, Bucket>(256, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, Bucket> eldest) {
return size() > MAX_BUCKETS;
}
});
@Override
public RateLimitDecision tryConsume(String bucketKey, long capacity, Duration refillPeriod) {
String compositeKey = bucketKey + "|" + capacity + "|" + refillPeriod.toNanos();
Bucket bucket =
buckets.computeIfAbsent(compositeKey, k -> buildBucket(capacity, refillPeriod));
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
return new RateLimitDecision(
probe.isConsumed(),
probe.getRemainingTokens(),
probe.isConsumed() ? 0L : probe.getNanosToWaitForRefill());
}
private static Bucket buildBucket(long capacity, Duration refillPeriod) {
Bandwidth limit =
Bandwidth.builder().capacity(capacity).refillGreedy(capacity, refillPeriod).build();
LocalBucketBuilder builder = Bucket.builder();
builder.addLimit(limit);
return builder.build();
}
}
@@ -1,128 +0,0 @@
package stirling.software.common.cluster.inprocess;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.FileStore;
/** Local-disk {@link FileStore} storing files under a base directory keyed by a UUID file id. */
@Slf4j
public class LocalDiskFileStore implements FileStore {
private final String baseDirPath;
public LocalDiskFileStore(String baseDirPath) {
this.baseDirPath = baseDirPath;
}
@Override
public Stored store(InputStream in, String originalName) throws IOException {
String fileId = UUID.randomUUID().toString();
Path filePath = resolve(fileId);
Files.createDirectories(filePath.getParent());
boolean success = false;
try {
long size = Files.copy(in, filePath);
success = true;
return new Stored(fileId, size);
} finally {
if (!success) {
try {
Files.deleteIfExists(filePath);
} catch (IOException cleanupEx) {
log.warn(
"Failed to clean up partial file {} after store failure",
filePath,
cleanupEx);
}
}
}
}
/**
* File-to-file copy. {@link Files#copy(Path, Path, java.nio.file.CopyOption...)} can use {@code
* sendfile(2)} on Linux for a zero-copy kernel transfer when source and destination share a
* filesystem, avoiding the streaming overhead of pulling the bytes through the JVM heap. Reads
* the source size before copying so the post-copy stat is unnecessary.
*/
@Override
public Stored store(Path source, String originalName) throws IOException {
String fileId = UUID.randomUUID().toString();
Path filePath = resolve(fileId);
Files.createDirectories(filePath.getParent());
long size = Files.size(source);
boolean success = false;
try {
Files.copy(source, filePath);
success = true;
return new Stored(fileId, size);
} finally {
if (!success) {
try {
Files.deleteIfExists(filePath);
} catch (IOException cleanupEx) {
log.warn(
"Failed to clean up partial file {} after store failure",
filePath,
cleanupEx);
}
}
}
}
@Override
public InputStream retrieve(String fileId) throws IOException {
return new BufferedInputStream(Files.newInputStream(resolve(fileId)));
}
@Override
public byte[] retrieveBytes(String fileId) throws IOException {
Path filePath = resolve(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
return Files.readAllBytes(filePath);
}
@Override
public long size(String fileId) throws IOException {
Path filePath = resolve(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
return Files.size(filePath);
}
@Override
public boolean delete(String fileId) {
try {
return Files.deleteIfExists(resolve(fileId));
} catch (IOException e) {
log.error("Error deleting file with ID: {}", fileId, e);
return false;
}
}
@Override
public boolean exists(String fileId) {
return Files.exists(resolve(fileId));
}
public Path resolve(String fileId) {
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
throw new IllegalArgumentException("Invalid file ID");
}
Path basePath = Path.of(baseDirPath).normalize().toAbsolutePath();
Path resolvedPath = basePath.resolve(fileId).normalize();
if (!resolvedPath.startsWith(basePath)) {
throw new IllegalArgumentException("File ID resolves to an invalid path");
}
return resolvedPath;
}
}
@@ -1,29 +0,0 @@
package stirling.software.common.cluster.inprocess;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import stirling.software.common.cluster.FileStore;
/**
* Always-on wiring for the per-node local-disk {@link FileStore}. Active when {@code
* cluster.artifactStore=local} (the default; {@code matchIfMissing=true}). The S3 artifact-store
* supplies its own bean when {@code cluster.artifactStore=s3}.
*/
@Configuration
@ConditionalOnProperty(
prefix = "cluster",
name = "artifactStore",
havingValue = "local",
matchIfMissing = true)
public class LocalDiskFileStoreConfiguration {
@Bean
@ConditionalOnMissingBean
public FileStore fileStore(@Value("${stirling.tempDir:/tmp/stirling-files}") String tempDir) {
return new LocalDiskFileStore(tempDir);
}
}
@@ -13,7 +13,6 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
@@ -78,7 +77,6 @@ public class ApplicationProperties {
private PdfEditor pdfEditor = new PdfEditor();
private AiEngine aiEngine = new AiEngine();
private InternalApi internalApi = new InternalApi();
private Cluster cluster = new Cluster();
@Bean
public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment environment)
@@ -256,117 +254,6 @@ public class ApplicationProperties {
private int longRunningTimeoutSeconds = 600;
}
/**
* Cluster backplane configuration. All keys live under the top-level {@code cluster.*} prefix
* (e.g. env var {@code CLUSTER_ENABLED}). The master switch is {@link #enabled} and defaults to
* off; when off the in-process backplane is wired and no other cluster keys are required.
*/
@Data
public static class Cluster {
/** Master switch. When {@code false} (default) the in-process backplane is wired. */
private boolean enabled = false;
/** Backplane implementation selector. Valid values: {@code inprocess} | {@code valkey}. */
private String backplane = "inprocess";
/**
* Transient cluster job-artifact store selector. Valid values: {@code local} | {@code s3}.
*
* <p>This is distinct from {@code storage.provider}, which selects the backend for
* persistent user-uploaded files. The two switches exist because the user-facing storage
* feature is optional ({@code storage.enabled=false} is common) but every multi-node
* cluster still needs a shared artifact store to serve cross-node downloads. Both
* implementations share credentials from {@code storage.s3.*} when set to {@code s3}.
*/
private String artifactStore = "local";
private Valkey valkey = new Valkey();
private Node node = new Node();
private Engine engine = new Engine();
private transient String cachedNodeId;
public NodeRole resolvedRole() {
if (node == null || node.getRole() == null) {
return NodeRole.BOTH;
}
String value = node.getRole().trim().toUpperCase(Locale.ROOT);
try {
return NodeRole.valueOf(value);
} catch (IllegalArgumentException ex) {
return NodeRole.BOTH;
}
}
public synchronized String resolvedNodeId() {
if (node != null && node.getId() != null && !node.getId().isBlank()) {
return node.getId();
}
if (cachedNodeId == null) {
cachedNodeId = UUID.randomUUID().toString();
}
return cachedNodeId;
}
public enum NodeRole {
WEB,
WORKER,
BOTH
}
@Data
public static class Valkey {
/**
* {@code redis://host:6379} or {@code rediss://...} for TLS. Required when cluster mode
* is on and backplane is valkey.
*/
private String url = "";
private Tls tls = new Tls();
@Data
public static class Tls {
/**
* When {@code true}, skip Valkey/Redis TLS certificate verification (dev/test
* only). Leave {@code false} in production.
*/
private boolean skipCertVerification = false;
}
}
@Data
public static class Node {
/** Optional explicit node id. Blank = auto-generated UUID at startup. */
private String id = "";
/** {@code web} | {@code worker} | {@code both}. */
private String role = "both";
/**
* Internal cluster address advertised in the instance registry (host:port). Blank =
* derived at startup.
*/
private String internalAddress = "";
/** {@code http} | {@code https} - scheme used when peers call this node. */
private String scheme = "http";
/** Heartbeat publish interval for the instance registry, in milliseconds. */
private long heartbeatIntervalMs = 5000;
}
@Data
public static class Engine {
/**
* Shared secret for the {@code X-Engine-Auth} header on Java -&gt; Python engine calls.
* Must match {@code STIRLING_ENGINE_SHARED_SECRET} on the engine pod. Blank = dev mode
* (no auth) - never leave blank in production.
*/
private String sharedSecret = "";
}
}
/**
* HTTP timeouts for loopback calls to internal Stirling API endpoints, used by the AI workflow
* executor and the pipeline processor. A bounded read timeout prevents a hung tool (e.g. an
@@ -1,13 +1,15 @@
package stirling.software.common.service;
import java.io.ByteArrayInputStream;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@@ -16,11 +18,9 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.FileStore;
/**
* Service for storing and retrieving files with unique file IDs. Used by the AutoJobPostMapping
* system to handle file references. Disk I/O is delegated to the injected {@link FileStore} bean.
* system to handle file references.
*/
@Service
@RequiredArgsConstructor
@@ -30,150 +30,251 @@ public class FileStorage {
/** Holds the result of a stream-to-disk store operation: the file ID and the bytes written. */
public record StoredFile(String fileId, long size) {}
@Value("${stirling.tempDir:/tmp/stirling-files}")
private String tempDirPath;
private final FileOrUploadService fileOrUploadService;
private final FileStore fileStore;
/**
* Store a file and return its unique ID
*
* @param file The file to store
* @return The unique ID assigned to the file
* @throws IOException If there is an error storing the file
*/
public String storeFile(MultipartFile file) throws IOException {
// Fast path: when Spring buffered the multipart to disk (typical for large uploads), the
// backing Resource exposes a real File. Hand the Path to the FileStore so it can do a
// file-to-file copy (Linux sendfile, no copy through Java heap) rather than streaming
// the bytes through an 8K buffer. Falls back to the InputStream path for in-memory
// multiparts, exotic Resource impls, and anything that does not back onto a File.
Resource res;
try {
res = file.getResource();
} catch (RuntimeException ignored) {
res = null;
}
if (res != null && res.isFile()) {
try {
FileStore.Stored stored =
fileStore.store(res.getFile().toPath(), file.getOriginalFilename());
log.debug("Stored file with ID: {} (fast path)", stored.fileId());
return stored.fileId();
} catch (IOException ex) {
// Some Resource impls advertise isFile()=true but throw on getFile(); fall through.
log.debug("Resource fast path failed, falling back to stream copy", ex);
}
}
try (InputStream in = file.getInputStream()) {
FileStore.Stored stored = fileStore.store(in, file.getOriginalFilename());
log.debug("Stored file with ID: {}", stored.fileId());
return stored.fileId();
}
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
// Ensure the directory exists
Files.createDirectories(filePath.getParent());
// Transfer the file to the storage location
file.transferTo(filePath.toFile());
log.debug("Stored file with ID: {}", fileId);
return fileId;
}
/**
* Store a byte array as a file and return its unique ID
*
* @param bytes The byte array to store
* @param originalName The original name of the file (for extension)
* @return The unique ID assigned to the file
* @throws IOException If there is an error storing the file
*/
public String storeBytes(byte[] bytes, String originalName) throws IOException {
FileStore.Stored stored = fileStore.store(new ByteArrayInputStream(bytes), originalName);
log.debug("Stored byte array with ID: {}", stored.fileId());
return stored.fileId();
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
// Ensure the directory exists
Files.createDirectories(filePath.getParent());
// Write the bytes to the file
Files.write(filePath, bytes);
log.debug("Stored byte array with ID: {}", fileId);
return fileId;
}
/**
* Retrieve a file by its ID as a MultipartFile
*
* @param fileId The ID of the file to retrieve
* @return The file as a MultipartFile
* @throws IOException If the file doesn't exist or can't be read
*/
public MultipartFile retrieveFile(String fileId) throws IOException {
byte[] fileData = fileStore.retrieveBytes(fileId);
Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
byte[] fileData = Files.readAllBytes(filePath);
return fileOrUploadService.toMockMultipartFile(fileId, fileData);
}
/**
* Retrieve a file by its ID as a byte array
*
* @param fileId The ID of the file to retrieve
* @return The file as a byte array
* @throws IOException If the file doesn't exist or can't be read
*/
public byte[] retrieveBytes(String fileId) throws IOException {
return fileStore.retrieveBytes(fileId);
Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
return Files.readAllBytes(filePath);
}
/**
* Retrieve a file by its ID as a streaming InputStream. The caller is responsible for closing
* the returned stream.
*
* @param fileId The ID of the file to retrieve
* @return A buffered InputStream for the file
* @throws IOException If the file doesn't exist or can't be read
*/
public InputStream retrieveInputStream(String fileId) throws IOException {
return fileStore.retrieve(fileId);
Path filePath = getFilePath(fileId);
// Let Files.newInputStream throw NoSuchFileException naturally — avoids TOCTOU race
// between exists-check and open when another thread may delete concurrently.
return new BufferedInputStream(Files.newInputStream(filePath));
}
/**
* Store data from an InputStream as a file and return its unique ID and byte count. Streams
* directly to disk without buffering the entire content in heap.
*
* @param inputStream The input stream to read from
* @param originalName The original name of the file (unused, kept for API symmetry)
* @return A {@link StoredFile} containing the file ID and the number of bytes written
* @throws IOException If there is an error storing the file
*/
public StoredFile storeInputStream(InputStream inputStream, String originalName)
throws IOException {
FileStore.Stored stored = fileStore.store(inputStream, originalName);
log.debug("Stored input stream with ID: {}", stored.fileId());
return new StoredFile(stored.fileId(), stored.size());
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
Files.createDirectories(filePath.getParent());
long size = Files.copy(inputStream, filePath);
log.debug("Stored input stream with ID: {}", fileId);
return new StoredFile(fileId, size);
}
public String storeFromStreamingBody(StreamingResponseBody body, String originalName)
throws IOException {
// Hold Throwable not IOException: an unchecked failure (NPE, IllegalState, OOM, etc.)
// from the body writer would otherwise close the pipe with EOF and the consumer would
// return a truncated file with no error surfaced to the caller.
AtomicReference<Throwable> bodyError = new AtomicReference<>();
try (PipedOutputStream out = new PipedOutputStream();
PipedInputStream in = new PipedInputStream(out, 8192)) {
var executor = Executors.newSingleThreadExecutor(Thread.ofVirtual().factory());
java.util.concurrent.Future<?> task = null;
try {
task =
executor.submit(
() -> {
try {
body.writeTo(out);
} catch (Throwable ex) {
bodyError.set(ex);
} finally {
try {
out.close();
} catch (IOException ignored) {
// closed on the consumer side too
}
}
});
FileStore.Stored stored = fileStore.store(in, originalName);
Throwable writerErr = bodyError.get();
if (writerErr != null) {
// Body failed mid-write: the FileStore persisted a truncated entry.
// Best-effort delete so we don't leak partial files; never let cleanup
// mask the original writer error.
try {
fileStore.delete(stored.fileId());
} catch (RuntimeException cleanupEx) {
log.warn(
"Failed to delete partial file {} after writer error: {}",
stored.fileId(),
cleanupEx.getMessage());
}
if (writerErr instanceof IOException ioe) {
throw ioe;
}
throw new IOException(
"StreamingResponseBody writer failed: " + writerErr.getMessage(),
writerErr);
}
log.debug("Stored StreamingResponseBody with ID: {}", stored.fileId());
return stored.fileId();
} finally {
// Interrupt and join the writer task: shutdown() alone returns immediately and a
// failed store leaves the writer running, leaking a thread per failed upload.
if (task != null && !task.isDone()) {
task.cancel(true);
}
executor.shutdown();
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
Files.createDirectories(filePath.getParent());
boolean success = false;
try (OutputStream os = new BufferedOutputStream(Files.newOutputStream(filePath))) {
body.writeTo(os);
success = true;
} finally {
if (!success) {
try {
if (!executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException ie) {
executor.shutdownNow();
Thread.currentThread().interrupt();
Files.deleteIfExists(filePath);
} catch (IOException cleanupEx) {
log.warn(
"Failed to clean up partial file {} after store failure",
filePath,
cleanupEx);
}
}
}
log.debug("Stored StreamingResponseBody with ID: {}", fileId);
return fileId;
}
/**
* Persist a {@link Resource} body to disk, returning the generated file ID. Used by the async
* job pipeline to capture {@code ResponseEntity<Resource>} results produced by controllers.
*/
public String storeFromResource(Resource resource, String originalName) throws IOException {
String fileId = generateFileId();
Path filePath = getFilePath(fileId);
Files.createDirectories(filePath.getParent());
boolean success = false;
try (InputStream in = resource.getInputStream()) {
FileStore.Stored stored = fileStore.store(in, originalName);
log.debug("Stored Resource with ID: {}", stored.fileId());
return stored.fileId();
Files.copy(in, filePath);
success = true;
} finally {
if (!success) {
try {
Files.deleteIfExists(filePath);
} catch (IOException cleanupEx) {
log.warn(
"Failed to clean up partial file {} after store failure",
filePath,
cleanupEx);
}
}
}
log.debug("Stored Resource with ID: {}", fileId);
return fileId;
}
/**
* Delete a file by its ID
*
* @param fileId The ID of the file to delete
* @return true if the file was deleted, false otherwise
*/
public boolean deleteFile(String fileId) {
try {
Path filePath = getFilePath(fileId);
return Files.deleteIfExists(filePath);
} catch (IOException e) {
log.error("Error deleting file with ID: {}", fileId, e);
return false;
}
}
public boolean deleteFile(String fileId) {
return fileStore.delete(fileId);
}
/**
* Check if a file exists by its ID
*
* @param fileId The ID of the file to check
* @return true if the file exists, false otherwise
*/
public boolean fileExists(String fileId) {
return fileStore.exists(fileId);
Path filePath = getFilePath(fileId);
return Files.exists(filePath);
}
/**
* Get the size of a file by its ID without loading the content into memory
*
* @param fileId The ID of the file
* @return The size of the file in bytes
* @throws IOException If the file doesn't exist or can't be read
*/
public long getFileSize(String fileId) throws IOException {
return fileStore.size(fileId);
Path filePath = getFilePath(fileId);
if (!Files.exists(filePath)) {
throw new IOException("File not found with ID: " + fileId);
}
return Files.size(filePath);
}
/**
* Get the path for a file ID
*
* @param fileId The ID of the file
* @return The path to the file
* @throws IllegalArgumentException if fileId contains path traversal characters or resolves
* outside base directory
*/
private Path getFilePath(String fileId) {
// Validate fileId to prevent path traversal
if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) {
throw new IllegalArgumentException("Invalid file ID");
}
Path basePath = Path.of(tempDirPath).normalize().toAbsolutePath();
Path resolvedPath = basePath.resolve(fileId).normalize();
// Ensure resolved path is within the base directory
if (!resolvedPath.startsWith(basePath)) {
throw new IllegalArgumentException("File ID resolves to an invalid path");
}
return resolvedPath;
}
/**
* Generate a unique file ID
*
* @return A unique file ID
*/
private String generateFileId() {
return UUID.randomUUID().toString();
}
}
@@ -3,13 +3,9 @@ package stirling.software.common.service;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -20,7 +16,6 @@ import java.util.concurrent.TimeUnit;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
@@ -31,10 +26,6 @@ import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.cluster.JobStoreEntry;
import stirling.software.common.cluster.JobStoreEntry.JobState;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
import stirling.software.common.model.job.ResultFile;
@@ -49,20 +40,20 @@ public class TaskManager {
private int jobResultExpiryMinutes = 30;
private final FileStorage fileStorage;
private final JobStore jobStore;
private final ClusterBackplane clusterBackplane;
private final ScheduledExecutorService cleanupExecutor =
Executors.newSingleThreadScheduledExecutor(
Thread.ofVirtual().name("task-cleanup-", 0).factory());
@Autowired
public TaskManager(
FileStorage fileStorage, JobStore jobStore, ClusterBackplane clusterBackplane) {
/** Initialize the task manager and start the cleanup scheduler */
public TaskManager(FileStorage fileStorage) {
this.fileStorage = fileStorage;
this.jobStore = jobStore;
this.clusterBackplane = clusterBackplane;
cleanupExecutor.scheduleAtFixedRate(this::cleanupOldJobs, 10, 10, TimeUnit.MINUTES);
// Schedule periodic cleanup of old job results
cleanupExecutor.scheduleAtFixedRate(
this::cleanupOldJobs,
10, // Initial delay
10, // Interval
TimeUnit.MINUTES);
log.debug(
"Task manager initialized with job result expiry of {} minutes",
@@ -75,9 +66,7 @@ public class TaskManager {
* @param jobId The job ID
*/
public void createTask(String jobId) {
JobResult result = JobResult.createNew(jobId);
jobResults.put(jobId, result);
writeThrough(jobId, result);
jobResults.put(jobId, JobResult.createNew(jobId));
log.debug("Created task with job ID: {}", jobId);
}
@@ -90,7 +79,6 @@ public class TaskManager {
public void setResult(String jobId, Object result) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.completeWithResult(result);
writeThrough(jobId, jobResult);
log.debug("Set result for job ID: {}", jobId);
}
@@ -113,7 +101,6 @@ public class TaskManager {
extractZipToIndividualFiles(fileId, originalFileName);
if (!extractedFiles.isEmpty()) {
jobResult.completeWithFiles(extractedFiles);
writeThrough(jobId, jobResult);
log.debug(
"Set multiple file results for job ID: {} with {} files extracted from"
+ " ZIP",
@@ -140,7 +127,6 @@ public class TaskManager {
"Failed to get file size for job {}: {}. Using size 0.", jobId, e.getMessage());
jobResult.completeWithSingleFile(fileId, originalFileName, contentType, 0);
}
writeThrough(jobId, jobResult);
}
/**
@@ -152,7 +138,6 @@ public class TaskManager {
public void setMultipleFileResults(String jobId, List<ResultFile> resultFiles) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.completeWithFiles(resultFiles);
writeThrough(jobId, jobResult);
log.debug(
"Set multiple file results for job ID: {} with {} files",
jobId,
@@ -168,7 +153,6 @@ public class TaskManager {
public void setError(String jobId, String error) {
JobResult jobResult = getOrCreateJobResult(jobId);
jobResult.failWithError(error);
writeThrough(jobId, jobResult);
log.debug("Set error for job ID: {}: {}", jobId, error);
}
@@ -185,7 +169,6 @@ public class TaskManager {
// If no result or error has been set, mark it as complete with an empty result
jobResult.completeWithResult("Task completed successfully");
}
writeThrough(jobId, jobResult);
log.debug("Marked job ID: {} as complete", jobId);
}
@@ -222,7 +205,6 @@ public class TaskManager {
JobResult jobResult = jobResults.get(jobId);
if (jobResult != null) {
jobResult.addNote(note);
writeThrough(jobId, jobResult);
log.debug("Added note to job ID: {}: {}", jobId, note);
return true;
}
@@ -313,11 +295,8 @@ public class TaskManager {
return jobResults.computeIfAbsent(jobId, JobResult::createNew);
}
/** Clean up old completed job results. No-op in cluster mode; the backplane TTL owns expiry. */
/** Clean up old completed job results */
public void cleanupOldJobs() {
if (clusterBackplane != null && !clusterBackplane.shouldRunLocalCleanup()) {
return;
}
LocalDateTime expiryThreshold =
LocalDateTime.now().minus(jobResultExpiryMinutes, ChronoUnit.MINUTES);
int removedCount = 0;
@@ -336,9 +315,6 @@ public class TaskManager {
// Remove the job result
jobResults.remove(entry.getKey());
if (jobStore != null) {
jobStore.delete(entry.getKey());
}
removedCount++;
}
}
@@ -351,53 +327,6 @@ public class TaskManager {
}
}
/** Mirror the in-memory {@code JobResult} into the cluster-visible {@link JobStore}. */
private void writeThrough(String jobId, JobResult result) {
if (jobStore == null) {
return;
}
try {
jobStore.put(toEntry(jobId, result), Duration.ofMinutes(jobResultExpiryMinutes));
} catch (RuntimeException ex) {
log.warn("JobStore write-through failed for job {}: {}", jobId, ex.getMessage());
}
}
private JobStoreEntry toEntry(String jobId, JobResult result) {
JobState state;
if (result.isComplete()) {
state = result.getError() != null ? JobState.FAILED : JobState.COMPLETE;
} else {
state = JobState.PENDING;
}
Instant createdAt = toInstant(result.getCreatedAt());
Instant completedAt = toInstant(result.getCompletedAt());
List<String> fileIds = new ArrayList<>();
if (result.hasFiles()) {
for (ResultFile rf : result.getAllResultFiles()) {
fileIds.add(rf.getFileId());
}
}
Map<String, String> meta = new HashMap<>();
if (result.getNotes() != null && !result.getNotes().isEmpty()) {
meta.put("notesCount", Integer.toString(result.getNotes().size()));
}
String owningNodeId = clusterBackplane == null ? "local" : clusterBackplane.localNodeId();
return new JobStoreEntry(
jobId,
state,
owningNodeId,
createdAt,
completedAt,
result.getError(),
fileIds,
meta);
}
private Instant toInstant(LocalDateTime ldt) {
return ldt == null ? null : ldt.atZone(ZoneId.systemDefault()).toInstant();
}
/** Shutdown the cleanup executor */
@PreDestroy
public void shutdown() {
@@ -441,7 +370,7 @@ public class TaskManager {
while ((entry = zipIn.getNextEntry()) != null) {
if (!entry.isDirectory()) {
String contentType = determineContentType(entry.getName());
// storeInputStream returns the fileId and byte count - no extra stat needed
// storeInputStream returns the fileId and byte count — no extra stat needed
FileStorage.StoredFile stored =
fileStorage.storeInputStream(zipIn, entry.getName());
@@ -529,8 +458,7 @@ public class TaskManager {
}
/**
* Find the job key that owns a given file ID. Checks the local in-memory map first, then falls
* back to the cluster-visible {@link JobStore}.
* Find the job key that owns a given file ID.
*
* @param fileId file identifier to look up
* @return scoped job key if found, otherwise null
@@ -546,18 +474,6 @@ public class TaskManager {
}
}
}
if (jobStore != null) {
// Propagate JobStore failures: returning null on a backplane outage would conflate
// "no such file" with "lookup unavailable" and the caller would respond 404 to a
// transient blip that should be retried. Let Spring's exception handler surface a
// 5xx so clients know to retry.
try {
return jobStore.findJobIdByFileId(fileId).orElse(null);
} catch (RuntimeException e) {
log.warn("JobStore findJobIdByFileId failed for {}: {}", fileId, e.getMessage());
throw e;
}
}
return null;
}
}
@@ -56,17 +56,4 @@ class ArchitectureTest {
.resideInAPackage("stirling.software.saas..");
rule.check(commonClasses);
}
@Test
void clusterInterfacesHaveNoImplementationDependencies() {
ArchRule rule =
noClasses()
.that()
.resideInAPackage("stirling.software.common.cluster..")
.should()
.dependOnClassesThat()
.resideInAnyPackage(
"stirling.software.proprietary..", "stirling.software.saas..");
rule.check(commonClasses);
}
}
@@ -1,51 +0,0 @@
package stirling.software.common.cluster;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class BackplaneContractCompilationTest {
@Test
void jobStoreEntryRecordRoundTrips() {
Instant now = Instant.now();
JobStoreEntry entry =
new JobStoreEntry(
"job-1",
JobStoreEntry.JobState.PENDING,
"node-a",
now,
null,
null,
List.of("file-1"),
Map.of("k", "v"));
assertEquals("job-1", entry.jobId());
assertEquals(JobStoreEntry.JobState.PENDING, entry.state());
assertEquals("node-a", entry.owningNodeId());
assertEquals(now, entry.createdAt());
assertEquals(List.of("file-1"), entry.fileIds());
assertEquals("v", entry.resultMeta().get("k"));
}
@Test
void clusterNodeRecordRoundTrips() {
Instant heartbeat = Instant.now();
ClusterNode node = new ClusterNode("node-a", "10.0.0.1:8080", heartbeat, "BOTH");
assertEquals("node-a", node.nodeId());
assertEquals("10.0.0.1:8080", node.internalAddress());
assertEquals(heartbeat, node.lastHeartbeat());
assertEquals("BOTH", node.role());
}
@Test
void rateLimitDecisionRecordRoundTrips() {
RateLimitStore.RateLimitDecision d = new RateLimitStore.RateLimitDecision(true, 7, 0L);
assertEquals(true, d.allowed());
assertEquals(7, d.remainingTokens());
assertEquals(0L, d.nanosToWaitForRefill());
}
}
@@ -1,65 +0,0 @@
package stirling.software.common.cluster;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.lang.reflect.Method;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Cluster;
class ClusterConfigValidationTest {
@Test
void validationPassesWhenDisabled() {
ApplicationProperties props = new ApplicationProperties();
ClusterConfig config = new ClusterConfig(props);
assertDoesNotThrow(() -> invokeValidate(config));
}
@Test
void validationFailsWhenValkeyEnabledWithoutUrl() {
ApplicationProperties props = new ApplicationProperties();
Cluster cluster = props.getCluster();
cluster.setEnabled(true);
cluster.setBackplane("valkey");
ClusterConfig config = new ClusterConfig(props);
assertThrows(IllegalStateException.class, () -> invokeValidate(config));
}
@Test
void validationPassesWhenValkeyEnabledWithUrl() {
ApplicationProperties props = new ApplicationProperties();
Cluster cluster = props.getCluster();
cluster.setEnabled(true);
cluster.setBackplane("valkey");
cluster.getValkey().setUrl("redis://localhost:6379");
ClusterConfig config = new ClusterConfig(props);
assertDoesNotThrow(() -> invokeValidate(config));
}
@Test
void validationPassesWhenInProcessEnabled() {
ApplicationProperties props = new ApplicationProperties();
Cluster cluster = props.getCluster();
cluster.setEnabled(true);
cluster.setBackplane("inprocess");
ClusterConfig config = new ClusterConfig(props);
assertDoesNotThrow(() -> invokeValidate(config));
}
private void invokeValidate(ClusterConfig config) throws Exception {
Method m = ClusterConfig.class.getDeclaredMethod("validate");
m.setAccessible(true);
try {
m.invoke(config);
} catch (java.lang.reflect.InvocationTargetException ex) {
if (ex.getCause() instanceof RuntimeException re) {
throw re;
}
throw ex;
}
}
}
@@ -1,62 +0,0 @@
package stirling.software.common.cluster;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Cluster;
class ClusterPropertiesTest {
@Test
void defaultsAreDisabledAndInprocess() {
Cluster props = new ApplicationProperties().getCluster();
assertFalse(props.isEnabled());
assertEquals("inprocess", props.getBackplane());
assertEquals("local", props.getArtifactStore());
assertEquals(Cluster.NodeRole.BOTH, props.resolvedRole());
assertEquals("", props.getValkey().getUrl());
assertFalse(props.getValkey().getTls().isSkipCertVerification());
assertEquals("both", props.getNode().getRole());
assertEquals("http", props.getNode().getScheme());
assertEquals(5000L, props.getNode().getHeartbeatIntervalMs());
}
@Test
void resolvedRoleParsesCaseInsensitively() {
Cluster props = new ApplicationProperties().getCluster();
props.getNode().setRole("WEB");
assertEquals(Cluster.NodeRole.WEB, props.resolvedRole());
props.getNode().setRole("web");
assertEquals(Cluster.NodeRole.WEB, props.resolvedRole());
props.getNode().setRole("Worker");
assertEquals(Cluster.NodeRole.WORKER, props.resolvedRole());
props.getNode().setRole("garbage");
assertEquals(Cluster.NodeRole.BOTH, props.resolvedRole());
props.getNode().setRole(null);
assertEquals(Cluster.NodeRole.BOTH, props.resolvedRole());
}
@Test
void resolvedNodeIdIsStableAcrossCalls() {
Cluster props = new ApplicationProperties().getCluster();
String first = props.resolvedNodeId();
String second = props.resolvedNodeId();
assertNotNull(first);
assertEquals(first, second);
}
@Test
void resolvedNodeIdHonoursExplicitId() {
Cluster props = new ApplicationProperties().getCluster();
props.getNode().setId("abc");
assertEquals("abc", props.resolvedNodeId());
}
}
@@ -1,90 +0,0 @@
package stirling.software.common.cluster;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import stirling.software.common.cluster.inprocess.InProcessClusterConfiguration;
import stirling.software.common.model.ApplicationProperties;
/**
* Verifies the {@link InProcessClusterConfiguration} conditional wiring: in-process beans wire when
* cluster mode is off or {@code backplane=inprocess}, and are skipped when {@code
* backplane=valkey}.
*/
class InProcessConfigurationConditionalTest {
private final ApplicationContextRunner runner =
new ApplicationContextRunner()
.withConfiguration(
org.springframework.boot.autoconfigure.AutoConfigurations.of(
PropertyPlaceholderAutoConfiguration.class))
.withUserConfiguration(
TestAppPropertiesConfig.class,
ClusterConfig.class,
InProcessClusterConfiguration.class);
@Test
void inProcessBeansWireWhenClusterDisabled() {
runner.run(
context ->
assertThat(context)
.hasNotFailed()
.hasSingleBean(ClusterBackplane.class)
.hasSingleBean(JobStore.class)
.hasSingleBean(RateLimitStore.class)
.hasSingleBean(DistributedLock.class)
.hasSingleBean(KeyValueCache.class)
.hasSingleBean(InstanceRegistry.class));
}
@Test
void inProcessBeansWireWhenEnabledWithInProcessBackplane() {
runner.withPropertyValues("cluster.enabled=true", "cluster.backplane=inprocess")
.run(
context ->
assertThat(context)
.hasNotFailed()
.hasSingleBean(ClusterBackplane.class)
.hasSingleBean(JobStore.class)
.hasSingleBean(RateLimitStore.class)
.hasSingleBean(DistributedLock.class)
.hasSingleBean(KeyValueCache.class)
.hasSingleBean(InstanceRegistry.class));
}
@Test
void inProcessBeansSkippedWhenEnabledWithDistributedBackplane() {
runner.withPropertyValues(
"cluster.enabled=true",
"cluster.backplane=valkey",
"cluster.valkey.url=redis://localhost:6379")
.run(
context ->
assertThat(context)
.hasNotFailed()
.doesNotHaveBean(ClusterBackplane.class)
.doesNotHaveBean(JobStore.class)
.doesNotHaveBean(RateLimitStore.class)
.doesNotHaveBean(DistributedLock.class)
.doesNotHaveBean(KeyValueCache.class)
.doesNotHaveBean(InstanceRegistry.class));
}
/**
* Hand-rolled {@link ApplicationProperties} bean: the production class loads YAML at startup
* via a {@code @PostConstruct} hook that isn't appropriate for the slice runner, so we wire a
* defaults-only instance here.
*/
@Configuration
static class TestAppPropertiesConfig {
@Bean
ApplicationProperties applicationProperties() {
return new ApplicationProperties();
}
}
}
@@ -1,167 +0,0 @@
package stirling.software.common.cluster.inprocess;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import stirling.software.common.cluster.DistributedLock;
class InProcessDistributedLockTest {
@Test
void acquireReleaseAcquire() {
DistributedLock lock = new InProcessDistributedLock();
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow();
h1.release();
assertTrue(lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent());
}
@Test
void reentryFromSameThreadFails() {
DistributedLock lock = new InProcessDistributedLock();
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow();
Optional<DistributedLock.LockHandle> reentry = lock.tryAcquire("k", Duration.ofSeconds(30));
assertFalse(reentry.isPresent(), "in-process lock must be non-reentrant");
h1.release();
// After release, anyone can acquire again.
assertTrue(lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent());
}
@Test
void secondAcquireFromAnotherThreadFails() throws InterruptedException {
DistributedLock lock = new InProcessDistributedLock();
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow();
CountDownLatch done = new CountDownLatch(1);
AtomicBoolean acquired = new AtomicBoolean(true);
Thread t =
new Thread(
() -> {
Optional<DistributedLock.LockHandle> attempt =
lock.tryAcquire("k", Duration.ofSeconds(30));
acquired.set(attempt.isPresent());
attempt.ifPresent(DistributedLock.LockHandle::release);
done.countDown();
});
t.start();
assertTrue(done.await(2, TimeUnit.SECONDS));
assertFalse(acquired.get());
h1.release();
}
@Test
void leaseExpiryAllowsTakeoverEvenWithoutRelease() throws InterruptedException {
// Acquire with a short lease, never call release, then try to acquire again after the
// lease has elapsed. Matches Redis SET-NX-EX semantics - the second caller gets the lock
// because the first lease auto-expired. 250ms lease + 350ms wait gives CI generous slack.
DistributedLock lock = new InProcessDistributedLock();
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofMillis(250)).orElseThrow();
Thread.sleep(350);
Optional<DistributedLock.LockHandle> takeover =
lock.tryAcquire("k", Duration.ofSeconds(30));
assertTrue(
takeover.isPresent(),
"expired lease must release the lock so a new caller can take over");
// Calling release() on the original handle after takeover must be a no-op (token check).
h1.release();
// The takeover holder is still the legitimate owner.
assertFalse(lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent());
takeover.get().release();
}
@Test
void renewExtendsLease() throws InterruptedException {
// Acquire with a short lease, renew it before it expires, then verify the lock is still
// held past the original expiry point. 200ms initial + renew to 2s + wait 350ms.
DistributedLock lock = new InProcessDistributedLock();
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofMillis(200)).orElseThrow();
assertTrue(h1.renew(Duration.ofSeconds(2)), "renew on a held lease must succeed");
Thread.sleep(350);
assertFalse(
lock.tryAcquire("k", Duration.ofSeconds(30)).isPresent(),
"renew should have pushed expiry well past the original 200ms");
h1.release();
}
@Test
void renewAfterReleaseFails() {
DistributedLock lock = new InProcessDistributedLock();
DistributedLock.LockHandle h1 = lock.tryAcquire("k", Duration.ofSeconds(30)).orElseThrow();
h1.release();
assertFalse(h1.renew(Duration.ofSeconds(30)), "renew on a released handle must fail");
}
/**
* Concurrency stress: many threads contending on the same key with each holder respecting the
* lease (hold &lt;&lt; lease). The lock behaves as a strict mutex in this regime so asserting
* mutual exclusion is meaningful. A separate test ({@link
* #leaseExpiryAllowsTakeoverEvenWithoutRelease}) covers the takeover-across-expiry branch,
* which legitimately allows two holders momentarily and is split-brain behaviour inherent to
* any lease-based lock.
*/
@Test
void concurrentContentionPreservesMutualExclusion() throws InterruptedException {
DistributedLock lock = new InProcessDistributedLock();
int threads = 16;
int attemptsPerThread = 200;
// Lease far exceeds any plausible hold time, so the takeover branch never triggers in
// this test and the lock acts as a strict mutex.
Duration lease = Duration.ofSeconds(5);
java.util.concurrent.atomic.AtomicInteger concurrentHolders =
new java.util.concurrent.atomic.AtomicInteger();
java.util.concurrent.atomic.AtomicInteger maxConcurrent =
new java.util.concurrent.atomic.AtomicInteger();
java.util.concurrent.atomic.AtomicInteger acquires =
new java.util.concurrent.atomic.AtomicInteger();
java.util.concurrent.atomic.AtomicReference<Throwable> firstFailure =
new java.util.concurrent.atomic.AtomicReference<>();
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
for (int i = 0; i < threads; i++) {
new Thread(
() -> {
try {
start.await();
for (int j = 0; j < attemptsPerThread; j++) {
Optional<DistributedLock.LockHandle> h =
lock.tryAcquire("hot", lease);
if (h.isPresent()) {
int now = concurrentHolders.incrementAndGet();
maxConcurrent.accumulateAndGet(now, Math::max);
acquires.incrementAndGet();
// Trivial critical section; well within lease.
concurrentHolders.decrementAndGet();
h.get().release();
}
}
} catch (Throwable t) {
firstFailure.compareAndSet(null, t);
} finally {
done.countDown();
}
},
"lock-stress-" + i)
.start();
}
start.countDown();
assertTrue(done.await(30, TimeUnit.SECONDS), "stress workers must finish in time");
org.junit.jupiter.api.Assertions.assertNull(firstFailure.get(), "no worker may throw");
org.junit.jupiter.api.Assertions.assertEquals(
1,
maxConcurrent.get(),
"mutual exclusion violated: more than one holder observed simultaneously");
assertTrue(
acquires.get() > 0,
"at least some acquires must succeed under contention (saw "
+ acquires.get()
+ ")");
}
}
@@ -1,28 +0,0 @@
package stirling.software.common.cluster.inprocess;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import stirling.software.common.cluster.ClusterNode;
class InProcessInstanceRegistryTest {
@Test
void registerThenLookupAndActiveNodes() {
InProcessInstanceRegistry registry = new InProcessInstanceRegistry();
ClusterNode node = new ClusterNode("node-1", "127.0.0.1:8080", Instant.now(), "BOTH");
registry.register(node, Duration.ofSeconds(30));
assertTrue(registry.lookup("node-1").isPresent());
assertEquals("node-1", registry.lookup("node-1").get().nodeId());
assertEquals(1, registry.activeNodes().size());
registry.deregister("node-1");
assertTrue(registry.lookup("node-1").isEmpty());
}
}
@@ -1,91 +0,0 @@
package stirling.software.common.cluster.inprocess;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import stirling.software.common.cluster.JobStoreEntry;
class InProcessJobStoreTest {
private final InProcessJobStore store = new InProcessJobStore();
@Test
void putGetDeleteExistsRoundTrip() {
JobStoreEntry entry = entry("job-1");
store.put(entry, Duration.ofMinutes(30));
assertTrue(store.exists("job-1"));
assertEquals(entry, store.get("job-1").orElseThrow());
store.delete("job-1");
assertFalse(store.exists("job-1"));
}
@Test
void ttlExpiry() throws InterruptedException {
store.put(entry("job-2"), Duration.ofMillis(50));
Thread.sleep(100);
assertFalse(store.get("job-2").isPresent());
}
@Test
void purgeExpiredRemovesOnlyStaleEntries() throws InterruptedException {
store.put(entry("job-fresh"), Duration.ofMinutes(30));
store.put(entry("job-stale"), Duration.ofMillis(20));
Thread.sleep(80);
int removed = store.purgeExpired();
assertEquals(1, removed);
assertTrue(store.exists("job-fresh"));
}
@Test
void findJobIdByFileIdReturnsTheRightJob() {
store.put(
new JobStoreEntry(
"job-a",
JobStoreEntry.JobState.COMPLETE,
"node-1",
Instant.now(),
Instant.now(),
null,
List.of("file-1", "file-2"),
Map.of()),
Duration.ofMinutes(30));
store.put(
new JobStoreEntry(
"job-b",
JobStoreEntry.JobState.COMPLETE,
"node-1",
Instant.now(),
Instant.now(),
null,
List.of("file-3"),
Map.of()),
Duration.ofMinutes(30));
assertEquals("job-a", store.findJobIdByFileId("file-1").orElseThrow());
assertEquals("job-b", store.findJobIdByFileId("file-3").orElseThrow());
assertFalse(store.findJobIdByFileId("missing").isPresent());
}
private JobStoreEntry entry(String id) {
return new JobStoreEntry(
id,
JobStoreEntry.JobState.PENDING,
"node-1",
Instant.now(),
null,
null,
List.of(),
Map.of());
}
}
@@ -1,41 +0,0 @@
package stirling.software.common.cluster.inprocess;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import stirling.software.common.cluster.KeyValueCache;
class InProcessKeyValueCacheTest {
@Test
void putGetEvict() {
KeyValueCache cache = new InProcessKeyValueCache();
cache.put("apikey", "a", "userA", Duration.ofMinutes(1));
assertEquals("userA", cache.get("apikey", "a").orElseThrow());
cache.evict("apikey", "a");
assertFalse(cache.get("apikey", "a").isPresent());
}
@Test
void ttlExpiry() throws InterruptedException {
KeyValueCache cache = new InProcessKeyValueCache();
cache.put("ns", "k", "v", Duration.ofMillis(40));
Thread.sleep(80);
assertFalse(cache.get("ns", "k").isPresent());
}
@Test
void evictNamespace() {
KeyValueCache cache = new InProcessKeyValueCache();
cache.put("ns", "a", "1", Duration.ofMinutes(1));
cache.put("ns", "b", "2", Duration.ofMinutes(1));
cache.evictNamespace("ns");
assertFalse(cache.get("ns", "a").isPresent());
assertFalse(cache.get("ns", "b").isPresent());
}
}
@@ -1,56 +0,0 @@
package stirling.software.common.cluster.inprocess;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import stirling.software.common.cluster.RateLimitStore;
import stirling.software.common.cluster.RateLimitStore.RateLimitDecision;
class InProcessRateLimitStoreTest {
@Test
void firstNConsumesAllowed() {
RateLimitStore store = new InProcessRateLimitStore();
for (int i = 0; i < 5; i++) {
assertTrue(store.tryConsume("k", 5, Duration.ofSeconds(60)).allowed(), "i=" + i);
}
assertFalse(store.tryConsume("k", 5, Duration.ofSeconds(60)).allowed());
}
@Test
void remainingTokensDecrements() {
RateLimitStore store = new InProcessRateLimitStore();
RateLimitDecision d1 = store.tryConsume("k", 5, Duration.ofSeconds(60));
RateLimitDecision d2 = store.tryConsume("k", 5, Duration.ofSeconds(60));
assertTrue(d1.allowed());
assertTrue(d2.allowed());
assertEquals(4, d1.remainingTokens());
assertEquals(3, d2.remainingTokens());
}
@Test
void refillRestoresTokens() throws InterruptedException {
RateLimitStore store = new InProcessRateLimitStore();
// Capacity 2 with smooth refill over 100 ms -> ~1 token per 50 ms.
for (int i = 0; i < 2; i++) {
assertTrue(store.tryConsume("k", 2, Duration.ofMillis(100)).allowed());
}
assertFalse(store.tryConsume("k", 2, Duration.ofMillis(100)).allowed());
Thread.sleep(150);
assertTrue(store.tryConsume("k", 2, Duration.ofMillis(100)).allowed());
}
@Test
void deniedConsumeReportsWaitNanos() {
RateLimitStore store = new InProcessRateLimitStore();
assertTrue(store.tryConsume("wait", 1, Duration.ofSeconds(10)).allowed());
RateLimitDecision denied = store.tryConsume("wait", 1, Duration.ofSeconds(10));
assertFalse(denied.allowed());
assertTrue(denied.nanosToWaitForRefill() > 0L);
}
}
@@ -1,42 +0,0 @@
package stirling.software.common.cluster.inprocess;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import stirling.software.common.cluster.FileStore;
class LocalDiskFileStoreTest {
@Test
void storeRetrieveSizeDeleteExistsRoundTrip(@TempDir Path dir) throws IOException {
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
byte[] payload = "hello-bytes".getBytes();
FileStore.Stored stored = store.store(new ByteArrayInputStream(payload), "x.txt");
assertEquals(payload.length, stored.size());
assertTrue(store.exists(stored.fileId()));
assertEquals(payload.length, store.size(stored.fileId()));
assertArrayEquals(payload, store.retrieveBytes(stored.fileId()));
assertTrue(store.delete(stored.fileId()));
assertFalse(store.exists(stored.fileId()));
}
@Test
void traversalIdsAreRejected(@TempDir Path dir) {
LocalDiskFileStore store = new LocalDiskFileStore(dir.toString());
assertThrows(IllegalArgumentException.class, () -> store.resolve("../foo"));
assertThrows(IllegalArgumentException.class, () -> store.resolve("a/b"));
assertThrows(IllegalArgumentException.class, () -> store.resolve("a\\b"));
}
}
@@ -1,27 +0,0 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.mockito.Mockito.mock;
import java.io.IOException;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import stirling.software.common.cluster.inprocess.LocalDiskFileStore;
class FileStorageDelegationTest {
@Test
void storeBytesThenRetrieveBytesRoundTripsThroughFileStore(@TempDir Path tempDir)
throws IOException {
FileStorage fs =
new FileStorage(
mock(FileOrUploadService.class),
new LocalDiskFileStore(tempDir.toString()));
byte[] payload = "round-trip".getBytes();
String id = fs.storeBytes(payload, "x.bin");
assertArrayEquals(payload, fs.retrieveBytes(id));
}
}
@@ -3,7 +3,6 @@ package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
@@ -14,30 +13,29 @@ import java.util.stream.Stream;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.common.cluster.inprocess.LocalDiskFileStore;
class FileStorageTest {
@TempDir Path tempDir;
@Mock private FileOrUploadService fileOrUploadService;
private FileStorage fileStorage;
@InjectMocks private FileStorage fileStorage;
private MultipartFile mockFile;
@BeforeEach
void setUp() throws IOException {
void setUp() {
MockitoAnnotations.openMocks(this);
fileStorage =
new FileStorage(fileOrUploadService, new LocalDiskFileStore(tempDir.toString()));
ReflectionTestUtils.setField(fileStorage, "tempDirPath", tempDir.toString());
// Create a mock MultipartFile
mockFile = mock(MultipartFile.class);
@@ -49,7 +47,17 @@ class FileStorageTest {
void testStoreFile() throws IOException {
// Arrange
byte[] fileContent = "Test PDF content".getBytes();
when(mockFile.getInputStream()).thenReturn(new ByteArrayInputStream(fileContent));
when(mockFile.getBytes()).thenReturn(fileContent);
// Set up mock to handle transferTo by writing the file
doAnswer(
invocation -> {
java.io.File file = invocation.getArgument(0);
Files.write(file.toPath(), fileContent);
return null;
})
.when(mockFile)
.transferTo(any(java.io.File.class));
// Act
String fileId = fileStorage.storeFile(mockFile);
@@ -57,7 +65,7 @@ class FileStorageTest {
// Assert
assertNotNull(fileId);
assertTrue(Files.exists(tempDir.resolve(fileId)));
assertArrayEquals(fileContent, Files.readAllBytes(tempDir.resolve(fileId)));
verify(mockFile).transferTo(any(java.io.File.class));
}
@Test
@@ -239,11 +247,11 @@ class FileStorageTest {
filesBefore = s.count();
}
// Act + Assert: IOException must propagate out - not be swallowed.
// Act + Assert: IOException must propagate out — not be swallowed.
assertThrows(
IOException.class, () -> fileStorage.storeFromResource(flakyResource, "n.pdf"));
// Assert: no partial file lingers under the storage directory - the finally
// Assert: no partial file lingers under the storage directory — the finally
// branch's deleteIfExists must have cleaned it up.
long filesAfter;
try (Stream<Path> s = Files.list(tempDir)) {
@@ -1,120 +0,0 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import java.time.LocalDateTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.cluster.JobStoreEntry;
import stirling.software.common.cluster.JobStoreEntry.JobState;
import stirling.software.common.cluster.inprocess.InProcessClusterBackplane;
import stirling.software.common.cluster.inprocess.InProcessJobStore;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.job.JobResult;
class TaskManagerJobStoreDelegationTest {
@Mock private FileStorage fileStorage;
private InProcessJobStore jobStore;
private ClusterBackplane backplane;
private TaskManager taskManager;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
jobStore = spy(new InProcessJobStore());
backplane = new InProcessClusterBackplane(new ApplicationProperties());
taskManager = new TaskManager(fileStorage, jobStore, backplane);
ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30);
}
@Test
void createTaskWritesPendingEntry() {
taskManager.createTask("job-1");
JobStoreEntry entry = jobStore.get("job-1").orElseThrow();
assertEquals(JobState.PENDING, entry.state());
assertEquals(backplane.localNodeId(), entry.owningNodeId());
}
@Test
void setCompleteFlipsToComplete() {
taskManager.createTask("job-2");
taskManager.setResult("job-2", "ok");
taskManager.setComplete("job-2");
JobStoreEntry entry = jobStore.get("job-2").orElseThrow();
assertEquals(JobState.COMPLETE, entry.state());
}
@Test
void setErrorFlipsToFailed() {
taskManager.createTask("job-3");
taskManager.setError("job-3", "boom");
JobStoreEntry entry = jobStore.get("job-3").orElseThrow();
assertEquals(JobState.FAILED, entry.state());
assertEquals("boom", entry.error());
}
@Test
void cleanupOldJobsIsNoopWhenBackplaneIsNotInProcess() {
ClusterBackplane mockedValkeyBackplane =
new ClusterBackplane() {
@Override
public boolean isHealthy() {
return true;
}
@Override
public String backplaneType() {
return "valkey";
}
@Override
public String localNodeId() {
return "node-1";
}
@Override
public boolean shouldRunLocalCleanup() {
return false;
}
};
TaskManager tm = new TaskManager(fileStorage, jobStore, mockedValkeyBackplane);
ReflectionTestUtils.setField(tm, "jobResultExpiryMinutes", 30);
tm.createTask("job-4");
tm.setComplete("job-4");
ageJobPastExpiry(tm, "job-4");
tm.cleanupOldJobs();
// cleanup must short-circuit before touching jobStore in cluster mode; the backplane
// TTL owns expiry there. If the gate fired correctly, delete is never called.
verify(jobStore, never()).delete(any());
}
@Test
void cleanupOldJobsDeletesFromJobStoreWhenBackplaneIsInProcess() {
taskManager.createTask("job-5");
taskManager.setComplete("job-5");
ageJobPastExpiry(taskManager, "job-5");
taskManager.cleanupOldJobs();
verify(jobStore).delete("job-5");
}
@SuppressWarnings("unchecked")
private static void ageJobPastExpiry(TaskManager tm, String jobId) {
var jobResults =
(java.util.Map<String, JobResult>) ReflectionTestUtils.getField(tm, "jobResults");
JobResult result = jobResults.get(jobId);
ReflectionTestUtils.setField(result, "completedAt", LocalDateTime.now().minusHours(2));
ReflectionTestUtils.setField(result, "complete", true);
}
}
@@ -1,28 +1,20 @@
package stirling.software.common.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.cluster.ClusterBackplane;
import stirling.software.common.cluster.JobStore;
import stirling.software.common.cluster.JobStoreEntry;
import stirling.software.common.cluster.JobStoreEntry.JobState;
import stirling.software.common.model.job.JobResult;
import stirling.software.common.model.job.JobStats;
import stirling.software.common.model.job.ResultFile;
@@ -30,8 +22,6 @@ import stirling.software.common.model.job.ResultFile;
class TaskManagerTest {
@Mock private FileStorage fileStorage;
@Mock private JobStore jobStore;
@Mock private ClusterBackplane clusterBackplane;
@InjectMocks private TaskManager taskManager;
@@ -40,10 +30,6 @@ class TaskManagerTest {
@BeforeEach
void setUp() {
closeable = MockitoAnnotations.openMocks(this);
// Treat the backplane as in-process so cleanupOldJobs is not short-circuited.
lenient().when(clusterBackplane.backplaneType()).thenReturn("inprocess");
lenient().when(clusterBackplane.localNodeId()).thenReturn("test-node");
lenient().when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(true);
ReflectionTestUtils.setField(taskManager, "jobResultExpiryMinutes", 30);
}
@@ -284,33 +270,6 @@ class TaskManagerTest {
verify(fileStorage).deleteFile("file-id");
}
@Test
void testCleanupOldJobs_NoOpWhenBackplaneOwnsExpiry() {
// When the backplane reports it should NOT run local cleanup (e.g. a distributed
// backplane with its own TTL), the cleanup loop must leave local state untouched.
when(clusterBackplane.shouldRunLocalCleanup()).thenReturn(false);
// Seed an old completed job that would normally be removed.
String oldJobId = "old-job-distributed";
taskManager.createTask(oldJobId);
JobResult oldJob = taskManager.getJobResult(oldJobId);
ReflectionTestUtils.setField(oldJob, "completedAt", LocalDateTime.now().minusHours(1));
ReflectionTestUtils.setField(oldJob, "complete", true);
Map<String, JobResult> jobResultsMap =
(Map<String, JobResult>) ReflectionTestUtils.getField(taskManager, "jobResults");
assertNotNull(jobResultsMap);
assertTrue(jobResultsMap.containsKey(oldJobId));
// Act
taskManager.cleanupOldJobs();
// Assert: nothing was removed locally, and no jobStore.delete was issued.
assertTrue(jobResultsMap.containsKey(oldJobId));
verify(jobStore, never()).delete(anyString());
verify(fileStorage, never()).deleteFile(anyString());
}
@Test
void testShutdown() {
// This mainly tests that the shutdown method doesn't throw exceptions
@@ -351,33 +310,4 @@ class TaskManagerTest {
// Assert
assertFalse(result);
}
@Test
void testWriteThroughOnUpdate() {
// Mutating calls must write through to the injected JobStore.
String jobId = "write-through-job";
taskManager.createTask(jobId);
taskManager.setResult(jobId, "done");
ArgumentCaptor<JobStoreEntry> captor = ArgumentCaptor.forClass(JobStoreEntry.class);
verify(jobStore, atLeast(2)).put(captor.capture(), any());
JobStoreEntry last = captor.getValue();
assertEquals(jobId, last.jobId());
assertEquals(JobState.COMPLETE, last.state());
assertEquals("test-node", last.owningNodeId());
}
@Test
void testFindJobKeyByFileId_FallsBackToJobStore() {
// When the file id is not in the local map, TaskManager delegates to JobStore.
String fileId = "remote-file-id";
String expectedJobKey = "remote-job-key";
when(jobStore.findJobIdByFileId(fileId)).thenReturn(Optional.of(expectedJobKey));
String actual = taskManager.findJobKeyByFileId(fileId);
assertEquals(expectedJobKey, actual);
verify(jobStore).findJobIdByFileId(fileId);
}
}
@@ -277,6 +277,9 @@ public class ConfigController {
// Premium/Enterprise settings
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
// AI Engine settings
configData.put("aiEngineEnabled", applicationProperties.getAiEngine().isEnabled());
// Timestamp TSA settings — single source of truth for presets + admin URLs
ApplicationProperties.Security.Timestamp tsConfig =
applicationProperties.getSecurity().getTimestamp();
@@ -330,24 +330,6 @@ aiEngine:
url: http://localhost:5001 # URL of the Python AI engine
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
# Cluster configuration. NOT YET ENABLED - scaffolding for later work. Leave at defaults.
cluster:
enabled: false # Master switch. 'false' (default) wires the in-process backplane and skips all cluster checks. Single-instance installs do not need to change anything here.
backplane: inprocess # Backplane implementation: 'inprocess' (single JVM only) or 'valkey' (multi-node via Valkey/Redis)
artifactStore: local # Transient cluster job-artifact backend: 'local' (per-node disk; single-node only) or 's3' (shared object store; required for multi-node). Distinct from 'storage.provider' which controls persistent user uploads - when both are 's3' they share the storage.s3.* credentials block. Multi-node deployments MUST set this to 's3'.
valkey:
url: "" # Valkey/Redis URL, e.g. 'redis://valkey:6379' or 'rediss://...' for TLS. Required when enabled=true and backplane=valkey.
tls:
skipCertVerification: false # set to 'true' to skip TLS certificate verification on Valkey connections (dev/test only)
node:
id: "" # Optional explicit node id. Blank = auto-generated UUID at startup.
role: both # 'web' (serves HTTP), 'worker' (runs jobs), or 'both' (default)
internalAddress: "" # host:port advertised in the instance registry for peer-to-peer cluster traffic. Blank = derived at startup.
scheme: http # 'http' or 'https' - scheme peers use to call this node's /internal/cluster/** endpoints
heartbeatIntervalMs: 5000 # Heartbeat publish interval for the instance registry (ms)
engine:
sharedSecret: "" # Shared secret for Java <-> Python AI engine auth. Generate via 'openssl rand -hex 32' and set BOTH this value and the engine pod's STIRLING_ENGINE_SHARED_SECRET env var to the same string. Blank = dev mode (auth disabled, NEVER in production).
pdfEditor:
fallback-font: classpath:/static/fonts/NotoSans-Regular.ttf # Override to point at a custom fallback font
cache:
@@ -87,7 +87,7 @@ public class AiEngineController {
@GetMapping("/health")
@Operation(
summary = "AI engine health check",
description = "Returns the health status of the AI engine.")
description = "Returns the health status of the AI engine including configured models")
public ResponseEntity<String> health() throws IOException {
String response = aiEngineClient.get("/health");
return ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body(response);
@@ -1,9 +1,7 @@
package stirling.software.proprietary.service;
import java.io.IOException;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
@@ -14,15 +12,9 @@ import java.util.stream.Stream;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
@@ -31,9 +23,6 @@ import stirling.software.common.model.ApplicationProperties;
@Service
public class AiEngineClient {
static final String ENGINE_AUTH_HEADER = "X-Engine-Auth";
static final String USER_ID_HEADER = "X-User-Id";
private final ApplicationProperties applicationProperties;
private final HttpClient httpClient;
@@ -54,113 +43,15 @@ public class AiEngineClient {
this.httpClient = httpClient;
}
@PostConstruct
void warnIfEngineSecretSentOverPlaintextHttp() {
String engineSecret = resolveEngineSecret();
if (engineSecret == null || engineSecret.isBlank()) {
return;
}
String engineUrl = applicationProperties.getAiEngine().getUrl();
if (engineUrl == null || !engineUrl.startsWith("http://")) {
return;
}
URI uri;
try {
uri = URI.create(engineUrl);
} catch (IllegalArgumentException ex) {
log.warn(
"Engine URL {} could not be parsed; skipping plaintext-secret check",
engineUrl);
return;
}
String host = uri.getHost();
if (host == null || isLoopbackOrPrivateHost(host)) {
return;
}
log.error(
"SECURITY: engine shared secret will be sent over plaintext HTTP to a non-loopback /"
+ " non-private-network host: {}. Use https:// for the engine URL in"
+ " production.",
engineUrl);
}
private static boolean isLoopbackOrPrivateHost(String host) {
// Cover the literal forms first to avoid a DNS lookup on the common cases.
if (host.equalsIgnoreCase("localhost")
|| host.equals("127.0.0.1")
|| host.equals("::1")
|| host.equals("0:0:0:0:0:0:0:1")) {
return true;
}
try {
InetAddress addr = InetAddress.getByName(host);
if (addr.isLoopbackAddress()
|| addr.isSiteLocalAddress()
|| addr.isLinkLocalAddress()) {
return true;
}
byte[] bytes = addr.getAddress();
if (bytes.length == 4) {
int b0 = bytes[0] & 0xFF;
int b1 = bytes[1] & 0xFF;
// 10/8
if (b0 == 10) return true;
// 172.16/12
if (b0 == 172 && b1 >= 16 && b1 <= 31) return true;
// 192.168/16
if (b0 == 192 && b1 == 168) return true;
}
return false;
} catch (UnknownHostException ex) {
// Cannot resolve - treat as remote so we err on the side of warning.
return false;
}
}
private HttpRequest.Builder decorate(HttpRequest.Builder builder) {
String secret = resolveEngineSecret();
if (secret != null && !secret.isBlank()) {
builder.header(ENGINE_AUTH_HEADER, secret);
}
String userId = resolveUserId();
if (userId != null && !userId.isBlank()) {
builder.header(USER_ID_HEADER, userId);
}
return builder;
}
private String resolveEngineSecret() {
ApplicationProperties.Cluster cluster = applicationProperties.getCluster();
if (cluster == null || cluster.getEngine() == null) {
return "";
}
return cluster.getEngine().getSharedSecret();
}
private String resolveUserId() {
try {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null
|| !auth.isAuthenticated()
|| auth instanceof AnonymousAuthenticationToken) {
return null;
}
Object principal = auth.getPrincipal();
if (principal instanceof UserDetails ud) {
return ud.getUsername();
}
return auth.getName();
} catch (RuntimeException ex) {
return null;
}
}
public String post(String path, String jsonBody) throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
return postWithTimeout(path, jsonBody, Duration.ofSeconds(config.getTimeoutSeconds()));
}
/** POST with an explicit per-call timeout for heavy operations (e.g. RAG ingestion). */
/**
* POST with an explicit per-call timeout, for heavy operations (e.g. RAG ingestion of a large
* document) that legitimately take longer than the default timeout.
*/
public String postLongRunning(String path, String jsonBody) throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
return postWithTimeout(
@@ -179,13 +70,12 @@ public class AiEngineClient {
log.debug("Proxying AI engine request to {} (timeout {}s)", url, timeout.toSeconds());
HttpRequest request =
decorate(
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody)))
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(timeout)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> response = sendRequest(request);
@@ -195,7 +85,15 @@ public class AiEngineClient {
return response.body();
}
/** POST a JSON body and consume the response as a stream of NDJSON lines. */
/**
* POST a JSON body and consume the response as a stream of NDJSON lines. Each line is passed to
* {@code lineConsumer} in arrival order; the call returns when the engine closes the stream.
*
* <p>This is the right shape for long-running orchestrator calls that emit incremental
* progress. The total HTTP timeout is the long-running timeout (typically 600s+), but in
* practice line arrival keeps the connection logically alive: as long as the engine emits
* events, the work is progressing. Genuine engine hangs still hit the total timeout.
*/
public void streamPost(String path, String jsonBody, Consumer<String> lineConsumer)
throws IOException {
ApplicationProperties.AiEngine config = applicationProperties.getAiEngine();
@@ -212,13 +110,12 @@ public class AiEngineClient {
timeout.toSeconds());
HttpRequest request =
decorate(
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/x-ndjson")
.timeout(timeout)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody)))
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", "application/json")
.header("Accept", "application/x-ndjson")
.timeout(timeout)
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<Stream<String>> response;
@@ -263,12 +160,11 @@ public class AiEngineClient {
log.debug("Proxying AI engine GET request to {}", url);
HttpRequest request =
decorate(
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
.GET())
HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(config.getTimeoutSeconds()))
.GET()
.build();
HttpResponse<String> response = sendRequest(request);
@@ -284,6 +180,9 @@ public class AiEngineClient {
} catch (HttpTimeoutException e) {
throw new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, "AI engine timed out", e);
} catch (IOException e) {
// Connection refused, DNS failure, socket reset, etc. — surface as
// SERVICE_UNAVAILABLE so every caller of this client sees a structured
// status rather than a raw 500 from an unhandled IOException.
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "AI engine unreachable: " + e.getMessage(), e);
} catch (InterruptedException e) {
@@ -1,42 +1,31 @@
package stirling.software.proprietary.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.net.ConnectException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.util.List;
import java.util.stream.Stream;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.server.ResponseStatusException;
import stirling.software.common.model.ApplicationProperties;
/**
* Verifies that AiEngineClient surfaces network-layer failures as structured HTTP statuses so every
* AI tool caller sees a consistent, meaningful error rather than a raw 500.
*/
class AiEngineClientTest {
private static final String ENGINE_SECRET = "shared-engine-secret";
private ApplicationProperties applicationProperties;
private HttpClient httpClient;
private AiEngineClient client;
@@ -49,12 +38,6 @@ class AiEngineClientTest {
applicationProperties.getAiEngine().setTimeoutSeconds(5);
httpClient = mock(HttpClient.class);
client = new AiEngineClient(applicationProperties, httpClient);
SecurityContextHolder.clearContext();
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test
@@ -101,127 +84,4 @@ class AiEngineClientTest {
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
}
// --- decorate() contract: X-Engine-Auth + X-User-Id stamping ---------------------------
@Test
void postStampsEngineAuthAndUserIdHeaders() throws Exception {
applicationProperties.getCluster().getEngine().setSharedSecret(ENGINE_SECRET);
authenticateAs("alice");
stubOkResponse();
client.post("/x", "{}");
HttpRequest sent = captureSentRequest();
assertThat(sent.headers().firstValue("X-Engine-Auth")).contains(ENGINE_SECRET);
assertThat(sent.headers().firstValue("X-User-Id")).contains("alice");
}
@Test
void getStampsEngineAuthAndUserIdHeaders() throws Exception {
applicationProperties.getCluster().getEngine().setSharedSecret(ENGINE_SECRET);
authenticateAs("bob");
stubOkResponse();
client.get("/x");
HttpRequest sent = captureSentRequest();
assertThat(sent.headers().firstValue("X-Engine-Auth")).contains(ENGINE_SECRET);
assertThat(sent.headers().firstValue("X-User-Id")).contains("bob");
}
@Test
void streamPostStampsEngineAuthAndUserIdHeaders() throws Exception {
applicationProperties.getCluster().getEngine().setSharedSecret(ENGINE_SECRET);
applicationProperties.getAiEngine().setLongRunningTimeoutSeconds(60);
authenticateAs("carol");
@SuppressWarnings("unchecked")
HttpResponse<Stream<String>> response =
(HttpResponse<Stream<String>>) mock(HttpResponse.class);
when(response.statusCode()).thenReturn(200);
when(response.body()).thenReturn(Stream.empty());
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(response);
client.streamPost("/stream", "{}", line -> {});
HttpRequest sent = captureSentRequest();
assertThat(sent.headers().firstValue("X-Engine-Auth")).contains(ENGINE_SECRET);
assertThat(sent.headers().firstValue("X-User-Id")).contains("carol");
}
@Test
void postOmitsEngineAuthWhenSharedSecretBlank() throws Exception {
applicationProperties.getCluster().getEngine().setSharedSecret("");
authenticateAs("alice");
stubOkResponse();
client.post("/x", "{}");
HttpRequest sent = captureSentRequest();
assertThat(sent.headers().firstValue("X-Engine-Auth"))
.as("blank engine secret must NOT produce a header")
.isEmpty();
assertThat(sent.headers().firstValue("X-User-Id")).contains("alice");
}
@Test
void postOmitsUserIdHeaderWhenSecurityContextEmpty() throws Exception {
// Unauthenticated context: X-User-Id must be omitted; X-Engine-Auth still applies.
applicationProperties.getCluster().getEngine().setSharedSecret(ENGINE_SECRET);
SecurityContextHolder.clearContext();
stubOkResponse();
client.post("/x", "{}");
HttpRequest sent = captureSentRequest();
assertThat(sent.headers().firstValue("X-Engine-Auth")).contains(ENGINE_SECRET);
assertThat(sent.headers().firstValue("X-User-Id"))
.as("anonymous context must NOT stamp an X-User-Id header")
.isEmpty();
}
@Test
void postOmitsUserIdHeaderForAnonymousAuthenticationToken() throws Exception {
// AnonymousAuthenticationToken#isAuthenticated() returns true and getName() ==
// "anonymousUser". resolveUserId() must special-case it so the engine never sees
// anonymous traffic conflated under the literal identity "anonymousUser".
applicationProperties.getCluster().getEngine().setSharedSecret(ENGINE_SECRET);
SecurityContextHolder.getContext()
.setAuthentication(
new AnonymousAuthenticationToken(
"key",
"anonymousUser",
List.of(new SimpleGrantedAuthority("ROLE_ANONYMOUS"))));
stubOkResponse();
client.post("/x", "{}");
HttpRequest sent = captureSentRequest();
assertThat(sent.headers().firstValue("X-User-Id"))
.as("anonymous principal must NOT stamp an X-User-Id header")
.isEmpty();
}
private void authenticateAs(String username) {
UserDetails principal =
User.withUsername(username).password("n/a").authorities("USER").build();
SecurityContextHolder.getContext()
.setAuthentication(
new UsernamePasswordAuthenticationToken(
principal, "n/a", principal.getAuthorities()));
}
@SuppressWarnings("unchecked")
private void stubOkResponse() throws Exception {
HttpResponse<String> response = (HttpResponse<String>) mock(HttpResponse.class);
when(response.statusCode()).thenReturn(200);
when(response.body()).thenReturn("{}");
when(httpClient.send(any(), any(HttpResponse.BodyHandler.class))).thenReturn(response);
}
private HttpRequest captureSentRequest() throws Exception {
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
verify(httpClient).send(captor.capture(), any(HttpResponse.BodyHandler.class));
return captor.getValue();
}
}
@@ -38,8 +38,8 @@ import stirling.software.proprietary.security.database.repository.UserRepository
import stirling.software.proprietary.security.model.User;
import stirling.software.saas.ai.model.AiCreateSession;
import stirling.software.saas.ai.repository.AiCreateSessionRepository;
import stirling.software.saas.ai.service.AiCreateProxyService;
import stirling.software.saas.ai.service.AiCreateSessionService;
import stirling.software.saas.ai.service.AiProxyService;
import stirling.software.saas.service.CreditService;
import stirling.software.saas.service.TeamCreditService;
import stirling.software.saas.util.AuthenticationUtils;
@@ -53,7 +53,7 @@ import stirling.software.saas.util.CreditHeaderUtils;
public class AiCreateController {
private final AiCreateSessionService sessionService;
private final AiProxyService proxyService;
private final AiCreateProxyService proxyService;
private final ObjectMapper objectMapper = new ObjectMapper();
private final CreditService creditService;
private final TeamCreditService teamCreditService;
@@ -0,0 +1,145 @@
package stirling.software.saas.ai.service;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.service.UserService;
@Service
@Profile("saas")
@Slf4j
public class AiCreateProxyService {
private static final String DEFAULT_AI_BASE_URL = "http://localhost:5001";
private final String aiServiceBaseUrl;
private final HttpClient httpClient;
private final UserRepository userRepository;
private final UserService userService;
public AiCreateProxyService(
@Value("${app.ai.service-base-url:" + DEFAULT_AI_BASE_URL + "}")
String aiServiceBaseUrl,
UserRepository userRepository,
UserService userService) {
this.aiServiceBaseUrl = aiServiceBaseUrl;
this.httpClient = HttpClient.newBuilder().build();
this.userRepository = userRepository;
this.userService = userService;
}
public HttpResponse<InputStream> forward(
String method, String path, HttpServletRequest request, boolean acceptEventStream)
throws IOException, InterruptedException {
String targetUrl = buildTargetUrl(path, request.getQueryString());
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(targetUrl));
String contentType = request.getContentType();
if (contentType != null && !contentType.isBlank()) {
builder.header("Content-Type", contentType);
}
String authorization = request.getHeader("Authorization");
if (authorization != null && !authorization.isBlank()) {
builder.header("Authorization", authorization);
}
// Extract user API key from authenticated user and forward to AI backend
String apiKey = request.getHeader("X-API-KEY");
if (apiKey == null || apiKey.isBlank()) {
apiKey = extractUserApiKey();
}
if (apiKey != null && !apiKey.isBlank()) {
builder.header("X-API-KEY", apiKey);
log.debug("Forwarding X-API-KEY header to AI backend");
}
String accept = request.getHeader("Accept");
if (acceptEventStream) {
builder.header("Accept", "text/event-stream");
} else if (accept != null && !accept.isBlank()) {
builder.header("Accept", accept);
}
builder.method(method, buildBodyPublisher(method, request));
log.debug("Proxying AI create request {} {}", method, targetUrl);
return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream());
}
private String buildTargetUrl(String path, String queryString) {
String baseUrl = aiServiceBaseUrl;
if (baseUrl == null || baseUrl.isBlank()) {
baseUrl = DEFAULT_AI_BASE_URL;
}
baseUrl = baseUrl.trim();
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
if (!path.startsWith("/")) {
path = "/" + path;
}
String url = baseUrl + path;
if (queryString != null && !queryString.isBlank()) {
url += "?" + queryString;
}
return url;
}
private HttpRequest.BodyPublisher buildBodyPublisher(
String method, HttpServletRequest request) {
if ("GET".equalsIgnoreCase(method) || "DELETE".equalsIgnoreCase(method)) {
return HttpRequest.BodyPublishers.noBody();
}
return HttpRequest.BodyPublishers.ofInputStream(
() -> {
try {
return request.getInputStream();
} catch (IOException exc) {
throw new UncheckedIOException(exc);
}
});
}
/**
* Extract the authenticated user's API key from the database. If the user doesn't have an API
* key, one will be created automatically.
*
* @return The user's API key, or null if not authenticated or key creation fails
*/
private String extractUserApiKey() {
try {
// Use getCurrentUsername() which handles all auth types including anonymous users
String username = userService.getCurrentUsername();
if (username == null || username.isBlank()) {
log.debug("No authenticated user found for API key extraction");
return null;
}
// getApiKeyForUser will create a key if it doesn't exist
String apiKey = userService.getApiKeyForUser(username);
log.debug("Retrieved API key for user: {}", username);
return apiKey;
} catch (Exception e) {
log.error(
"Failed to extract or create user API key for user: {}",
userService.getCurrentUsername(),
e);
return null;
}
}
}
@@ -20,20 +20,9 @@ import jakarta.servlet.http.Part;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.service.UserService;
/**
* Proxies saas-side HTTP requests to the AI engine and enforces the auth-header contract:
*
* <ul>
* <li>Client-supplied {@code Authorization} and {@code X-API-KEY} are stripped; the server-
* resolved API key is stamped so callers cannot spoof identity downstream.
* <li>{@code X-Engine-Auth} is stamped when the cluster shared secret is configured.
* <li>{@code X-User-Id} is stamped for authenticated principals; omitted for anonymous.
* </ul>
*/
@Service
@Profile("saas")
@Slf4j
@@ -41,29 +30,21 @@ public class AiProxyService {
private static final String DEFAULT_AI_BASE_URL = "http://localhost:5001";
private static final String ENGINE_AUTH_HEADER = "X-Engine-Auth";
private static final String USER_ID_HEADER = "X-User-Id";
private static final String API_KEY_HEADER = "X-API-KEY";
private static final String AUTHORIZATION_HEADER = "Authorization";
private final String aiServiceBaseUrl;
private final HttpClient httpClient;
private final UserRepository userRepository;
private final UserService userService;
private final ApplicationProperties applicationProperties;
public AiProxyService(
@Value("${app.ai.service-base-url:" + DEFAULT_AI_BASE_URL + "}")
String aiServiceBaseUrl,
UserRepository userRepository,
UserService userService,
ApplicationProperties applicationProperties) {
UserService userService) {
this.aiServiceBaseUrl = aiServiceBaseUrl;
this.httpClient = HttpClient.newBuilder().build();
this.userRepository = userRepository;
this.userService = userService;
this.applicationProperties = applicationProperties;
}
public HttpResponse<InputStream> forward(
@@ -74,6 +55,21 @@ public class AiProxyService {
String contentType = request.getContentType();
String authorization = request.getHeader("Authorization");
if (authorization != null && !authorization.isBlank()) {
builder.header("Authorization", authorization);
}
// Extract user API key from authenticated user and forward to AI backend
String apiKey = request.getHeader("X-API-KEY");
if (apiKey == null || apiKey.isBlank()) {
apiKey = extractUserApiKey();
}
if (apiKey != null && !apiKey.isBlank()) {
builder.header("X-API-KEY", apiKey);
log.debug("Forwarding X-API-KEY header to AI backend");
}
String accept = request.getHeader("Accept");
if (acceptEventStream) {
builder.header("Accept", "text/event-stream");
@@ -88,68 +84,10 @@ public class AiProxyService {
builder.header("Content-Type", contentType);
}
builder.method(method, body.publisher);
// INVARIANT: stamp auth headers LAST. setHeader() overrides any previously-set value,
// including any caller-supplied Authorization / X-API-KEY that arrived via this proxy.
// Any .header(...) call AFTER this point would defeat the strip-on-output guarantee.
stampAuthHeaders(builder);
log.debug("Proxying AI request {} {}", method, targetUrl);
return httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream());
}
private void stampAuthHeaders(HttpRequest.Builder builder) {
// setHeader (not header) with empty string causes the JDK builder to omit the header
// entirely in the built request, dropping any caller-supplied value.
builder.setHeader(AUTHORIZATION_HEADER, "");
String apiKey = extractUserApiKey();
if (apiKey != null && !apiKey.isBlank()) {
builder.setHeader(API_KEY_HEADER, apiKey);
log.debug("Attaching server-resolved X-API-KEY for the engine call");
} else {
builder.setHeader(API_KEY_HEADER, "");
}
String engineSecret = resolveEngineSecret();
if (engineSecret != null && !engineSecret.isBlank()) {
builder.setHeader(ENGINE_AUTH_HEADER, engineSecret);
}
try {
String username = userService.getCurrentUsername();
if (username != null && !username.isBlank() && !"anonymousUser".equals(username)) {
builder.setHeader(USER_ID_HEADER, username);
}
} catch (RuntimeException ex) {
log.warn("Could not resolve current username for X-User-Id header", ex);
}
}
private String resolveEngineSecret() {
ApplicationProperties.Cluster cluster = applicationProperties.getCluster();
if (cluster == null || cluster.getEngine() == null) {
return "";
}
return cluster.getEngine().getSharedSecret();
}
private String extractUserApiKey() {
try {
String username = userService.getCurrentUsername();
if (username == null || username.isBlank()) {
log.debug("No authenticated user found for API key extraction");
return null;
}
String apiKey = userService.getApiKeyForUser(username);
log.debug("Retrieved API key for user: {}", username);
return apiKey;
} catch (Exception e) {
log.error("Failed to extract or create user API key", e);
return null;
}
}
private String buildTargetUrl(String path, String queryString) {
String baseUrl = aiServiceBaseUrl;
if (baseUrl == null || baseUrl.isBlank()) {
@@ -238,6 +176,34 @@ public class AiProxyService {
output.write("\r\n".getBytes(StandardCharsets.UTF_8));
}
/**
* Extract the authenticated user's API key from the database. If the user doesn't have an API
* key, one will be created automatically.
*
* @return The user's API key, or null if not authenticated or key creation fails
*/
private String extractUserApiKey() {
try {
// Use getCurrentUsername() which handles all auth types including anonymous users
String username = userService.getCurrentUsername();
if (username == null || username.isBlank()) {
log.debug("No authenticated user found for API key extraction");
return null;
}
// getApiKeyForUser will create a key if it doesn't exist
String apiKey = userService.getApiKeyForUser(username);
log.debug("Retrieved API key for user: {}", username);
return apiKey;
} catch (Exception e) {
log.error(
"Failed to extract or create user API key for user: {}",
userService.getCurrentUsername(),
e);
return null;
}
}
private static class BodyPublisherWithContentType {
private final HttpRequest.BodyPublisher publisher;
private final String contentType;
@@ -1,157 +0,0 @@
package stirling.software.saas.ai.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.service.UserService;
/** Verifies the auth-header strip-and-stamp contract on the saas AI proxy. */
@ExtendWith(MockitoExtension.class)
class AiProxyServiceTest {
private static final String ENGINE_URL = "http://engine.local:5001";
private static final String ENGINE_SECRET = "shared-engine-secret";
private static final String SERVER_API_KEY = "server-resolved-api-key";
private static final String CURRENT_USERNAME = "alice";
@Mock UserRepository userRepository;
@Mock UserService userService;
@Mock HttpClient httpClient;
@Mock HttpResponse<InputStream> mockResponse;
private ApplicationProperties applicationProperties;
private AiProxyService proxy;
@BeforeEach
void setUp() throws Exception {
applicationProperties = new ApplicationProperties();
applicationProperties.getCluster().getEngine().setSharedSecret(ENGINE_SECRET);
proxy = new AiProxyService(ENGINE_URL, userRepository, userService, applicationProperties);
// Replace the internally-constructed HttpClient with our mock so we can capture requests.
ReflectionTestUtils.setField(proxy, "httpClient", httpClient);
lenient().when(mockResponse.body()).thenReturn(new ByteArrayInputStream(new byte[0]));
lenient().when(mockResponse.statusCode()).thenReturn(200);
when(httpClient.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
.thenReturn(mockResponse);
}
@ParameterizedTest
@ValueSource(strings = {"/api/v1/chat", "/api/create/sessions"})
void forward_dropsClientAuthorizationAndApiKeyHeaders(String path) throws Exception {
when(userService.getCurrentUsername()).thenReturn(CURRENT_USERNAME);
when(userService.getApiKeyForUser(CURRENT_USERNAME)).thenReturn(SERVER_API_KEY);
MockHttpServletRequest inbound = new MockHttpServletRequest("POST", path);
inbound.addHeader("Authorization", "Bearer client-supplied-bearer");
inbound.addHeader("X-API-KEY", "client-supplied-api-key");
inbound.setContentType("application/json");
inbound.setContent("{}".getBytes());
proxy.forward("POST", path, inbound, false);
HttpRequest outbound = captureOutboundRequest();
// setHeader with empty string causes the JDK builder to omit the header or send it empty.
assertThat(outbound.headers().firstValue("Authorization").orElse(""))
.as("client Authorization must never reach the engine")
.isEmpty();
assertThat(outbound.headers().allValues("Authorization"))
.as("client Authorization value must not be among the outbound values")
.doesNotContain("Bearer client-supplied-bearer");
Optional<String> apiKey = outbound.headers().firstValue("X-API-KEY");
assertThat(apiKey)
.as("X-API-KEY must be the server-resolved value, not the client-supplied one")
.contains(SERVER_API_KEY);
assertThat(apiKey.orElse(""))
.as("client-supplied X-API-KEY must not leak through")
.isNotEqualTo("client-supplied-api-key");
assertThat(outbound.headers().allValues("X-API-KEY"))
.as("client X-API-KEY must not be among the outbound values")
.doesNotContain("client-supplied-api-key");
}
@ParameterizedTest
@ValueSource(strings = {"/api/v1/chat", "/api/create/sessions"})
void forward_stampsEngineAuthAndUserIdHeaders(String path) throws Exception {
when(userService.getCurrentUsername()).thenReturn(CURRENT_USERNAME);
when(userService.getApiKeyForUser(CURRENT_USERNAME)).thenReturn(SERVER_API_KEY);
MockHttpServletRequest inbound = new MockHttpServletRequest("POST", path);
inbound.setContentType("application/json");
inbound.setContent("{}".getBytes());
proxy.forward("POST", path, inbound, false);
HttpRequest outbound = captureOutboundRequest();
assertThat(outbound.headers().firstValue("X-Engine-Auth"))
.as("engine shared secret must be stamped on outbound requests")
.contains(ENGINE_SECRET);
assertThat(outbound.headers().firstValue("X-User-Id"))
.as("authenticated username must be stamped on outbound requests")
.contains(CURRENT_USERNAME);
}
@Test
void forward_omitsEngineAuthWhenSecretBlank() throws Exception {
applicationProperties.getCluster().getEngine().setSharedSecret("");
when(userService.getCurrentUsername()).thenReturn(CURRENT_USERNAME);
when(userService.getApiKeyForUser(CURRENT_USERNAME)).thenReturn(SERVER_API_KEY);
MockHttpServletRequest inbound = new MockHttpServletRequest("GET", "/api/v1/health");
proxy.forward("GET", "/api/v1/health", inbound, false);
HttpRequest outbound = captureOutboundRequest();
assertThat(outbound.headers().firstValue("X-Engine-Auth"))
.as("blank engine secret must not produce a header")
.isEmpty();
}
@Test
void forward_omitsUserIdForAnonymousPrincipal() throws Exception {
when(userService.getCurrentUsername()).thenReturn("anonymousUser");
MockHttpServletRequest inbound =
new MockHttpServletRequest("GET", "/api/create/sessions/abc");
proxy.forward("GET", "/api/create/sessions/abc", inbound, false);
HttpRequest outbound = captureOutboundRequest();
assertThat(outbound.headers().firstValue("X-User-Id"))
.as("anonymous principal must not produce an X-User-Id header")
.isEmpty();
}
private HttpRequest captureOutboundRequest() throws Exception {
ArgumentCaptor<HttpRequest> captor = ArgumentCaptor.forClass(HttpRequest.class);
org.mockito.Mockito.verify(httpClient)
.send(captor.capture(), any(HttpResponse.BodyHandler.class));
return captor.getValue();
}
}
+9 -19
View File
@@ -1,9 +1,9 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import FastAPI
from fastapi import Depends, FastAPI
from pydantic_ai import Agent
from pydantic_ai.models.instrumented import InstrumentationSettings
@@ -16,7 +16,7 @@ from stirling.agents import (
)
from stirling.agents.ledger import MathAuditorAgent
from stirling.agents.pdf_comment import PdfCommentAgent
from stirling.api.middleware import EngineAuthMiddleware, UserIdMiddleware
from stirling.api.middleware import UserIdMiddleware
from stirling.api.routes import (
agent_draft_router,
document_router,
@@ -63,21 +63,7 @@ async def lifespan(fast_api: FastAPI):
app = FastAPI(title="Stirling AI Engine", lifespan=lifespan, version="0.1.0")
try:
_engine_shared_secret = load_settings().engine_shared_secret or ""
except (AttributeError, KeyError) as cfg_err:
raise RuntimeError(
"engine_shared_secret missing from settings; ensure STIRLING_ENGINE_SHARED_SECRET "
"is declared in the env (blank value is allowed for dev mode)."
) from cfg_err
if not _engine_shared_secret:
logging.getLogger(__name__).warning(
"STIRLING_ENGINE_SHARED_SECRET is blank - running in dev (open) mode."
)
app.add_middleware(UserIdMiddleware)
app.add_middleware(EngineAuthMiddleware, expected_secret=_engine_shared_secret)
app.include_router(orchestrator_router)
app.include_router(pdf_edit_router)
app.include_router(pdf_question_router)
@@ -89,5 +75,9 @@ app.include_router(pdf_comments_router)
@app.get("/health", response_model=HealthResponse)
async def healthcheck() -> HealthResponse:
return HealthResponse(status="ok")
async def healthcheck(settings: Annotated[AppSettings, Depends(load_settings)]) -> HealthResponse:
return HealthResponse(
status="ok",
smart_model=settings.smart_model_name,
fast_model=settings.fast_model_name,
)
+2 -24
View File
@@ -1,20 +1,16 @@
from __future__ import annotations
import hmac
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.responses import Response
from stirling.services.tracking import current_user_id
_USER_ID_HEADER = "X-User-Id"
_ENGINE_AUTH_HEADER = "X-Engine-Auth"
_HEALTH_PATHS = {"/health", "/healthz", "/readyz"}
class UserIdMiddleware(BaseHTTPMiddleware):
"""Set X-User-Id (stamped by the trusted Java proxy) as request context."""
"""Extract X-User-Id header and set it as the current user for PostHog tracking."""
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
user_id = request.headers.get(_USER_ID_HEADER)
@@ -25,21 +21,3 @@ class UserIdMiddleware(BaseHTTPMiddleware):
finally:
current_user_id.reset(token)
return await call_next(request)
class EngineAuthMiddleware(BaseHTTPMiddleware):
"""Validate shared-secret header. Blank secret = dev mode (open); health probes exempt."""
def __init__(self, app, expected_secret: str) -> None:
super().__init__(app)
self._expected_secret = expected_secret or ""
# Precompute the bytes form once so per-request work is just the constant-time compare.
self._expected_secret_bytes = self._expected_secret.encode("utf-8")
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if request.url.path in _HEALTH_PATHS or not self._expected_secret:
return await call_next(request)
presented = request.headers.get(_ENGINE_AUTH_HEADER, "")
if not hmac.compare_digest(presented.encode("utf-8"), self._expected_secret_bytes):
return JSONResponse({"detail": "engine authentication failed"}, status_code=401)
return await call_next(request)
-3
View File
@@ -98,9 +98,6 @@ class AppSettings(BaseSettings):
posthog_api_key: str = Field(validation_alias="STIRLING_POSTHOG_API_KEY")
posthog_host: str = Field(validation_alias="STIRLING_POSTHOG_HOST")
# Engine shared-secret for cluster deployments. Blank = dev mode (open).
engine_shared_secret: str = Field(default="", validation_alias="STIRLING_ENGINE_SHARED_SECRET")
def _configure_logging(level_name: str, log_file: str, http_debug: bool) -> None:
"""Configure the ``stirling`` logger hierarchy."""
+2
View File
@@ -5,3 +5,5 @@ from stirling.models import ApiModel
class HealthResponse(ApiModel):
status: str
smart_model: str
fast_model: str
@@ -1,59 +0,0 @@
"""Tests for EngineAuthMiddleware - shared-secret gating and health probe exemption."""
from __future__ import annotations
from fastapi import FastAPI
from fastapi.testclient import TestClient
from stirling.api.middleware import EngineAuthMiddleware
def _app(expected_secret: str) -> FastAPI:
app = FastAPI()
app.add_middleware(EngineAuthMiddleware, expected_secret=expected_secret)
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/v1/agent")
async def agent():
return {"ok": True}
return app
def test_correct_secret_allows_request():
client = TestClient(_app("s3cret"))
res = client.get("/v1/agent", headers={"X-Engine-Auth": "s3cret"})
assert res.status_code == 200
assert res.json() == {"ok": True}
def test_missing_header_rejected_with_401():
client = TestClient(_app("s3cret"))
res = client.get("/v1/agent")
assert res.status_code == 401
def test_wrong_header_rejected_with_401():
client = TestClient(_app("s3cret"))
res = client.get("/v1/agent", headers={"X-Engine-Auth": "wrong"})
assert res.status_code == 401
def test_blank_secret_dev_mode_allows_unauthenticated():
client = TestClient(_app(""))
res = client.get("/v1/agent")
assert res.status_code == 200
def test_health_endpoint_exempt_from_auth():
client = TestClient(_app("s3cret"))
res = client.get("/health")
assert res.status_code == 200
def test_health_endpoint_exempt_in_dev_mode():
client = TestClient(_app(""))
res = client.get("/health")
assert res.status_code == 200
-50
View File
@@ -1,50 +0,0 @@
"""Tests for UserIdMiddleware - X-User-Id propagates as context.
X-Tenant-Id is deliberately NOT accepted from the wire: the Java proxy does not stamp
one and a client-supplied tenant id would be spoofable. If/when tenant scoping arrives
it must be derived from the server-side security context, not from request headers.
"""
from __future__ import annotations
from fastapi import FastAPI, Request
from fastapi.testclient import TestClient
from stirling.api.middleware import UserIdMiddleware
from stirling.services.tracking import current_user_id
def _app() -> FastAPI:
app = FastAPI()
app.add_middleware(UserIdMiddleware)
@app.get("/me")
async def me(request: Request):
return {
"user_id": current_user_id.get(),
"tenant_id": getattr(request.state, "tenant_id", None),
}
return app
def test_user_id_header_is_propagated():
client = TestClient(_app())
res = client.get("/me", headers={"X-User-Id": "alice"})
assert res.status_code == 200
assert res.json()["user_id"] == "alice"
def test_tenant_id_header_is_ignored():
client = TestClient(_app())
res = client.get("/me", headers={"X-User-Id": "alice", "X-Tenant-Id": "acme"})
assert res.status_code == 200
body = res.json()
assert body["user_id"] == "alice"
assert body["tenant_id"] is None
def test_missing_user_id_returns_no_user_in_context():
client = TestClient(_app())
res = client.get("/me")
assert res.status_code == 200
assert res.json()["user_id"] in ("", None)
@@ -2672,6 +2672,47 @@ title = "Change Permissions"
[changePermissions.tooltip.warning]
text = "To make these permissions unchangeable, use the Add Password tool to set an owner password."
[agents]
section_title = "Agents"
fullscreen_title = "Stirling Agents"
stirling_name = "Stirling"
stirling_full_name = "Stirling General Agent"
stirling_tooltip = "Stirling agent"
stirling_description = "Your general-purpose PDF assistant"
stirling_long_description = "General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents."
back_to_tools = "Back to tools"
coming_soon = "Coming soon"
view_all = "View all agents"
show_less = "Show less"
start_chat = "Start chatting"
[chat.header]
settings = "Agent settings"
agentMenu = "Stirling agent options"
clearChat = "Clear chat"
[chat.input]
placeholder = "What do you want to do?"
send = "Send message"
attach = "Attach files"
[chat.quickActions]
heading = "Get started"
openFromComputer = "Open from computer"
browseYourFiles = "Browse your files"
rotateOne = "Rotate this document"
rotateMany = "Rotate these documents"
compressOne = "Compress this document"
compressMany = "Compress these documents"
mergeMany = "Merge these {{count}} documents into 1"
splitOne = "Split this document"
convertOne = "Convert this document to PDF"
convertMany = "Convert these documents to PDF"
fileSummary_one = "1 file in workbench ({{types}})"
fileSummary_other = "{{count}} files in workbench ({{types}})"
moreFiles = "+{{count}} more"
removeFile = "Remove {{name}}"
[chat.progress]
analyzing = "Analysing your request..."
calling_engine = "AI is thinking..."
@@ -6864,6 +6905,7 @@ theme = "Theme"
themeDark = "Dark"
themeDescription = "Switch between light and dark mode"
themeLight = "Light"
themeMidnight = "Midnight"
title = "General"
user = "User"
@@ -7910,6 +7952,11 @@ expand = "Expand panel"
placeholder = "Choose a tool to get started"
premiumFeature = "Premium feature:"
search = "Search tools"
toolsHeader = "Tools"
viewAllTools = "View all tools"
backToDefault = "Back"
backToAllTools = "Back to all tools"
goBack = "Go back"
[toolPanel.fullscreen]
comingSoon = "Coming soon:"
@@ -0,0 +1,217 @@
#!/usr/bin/env node
/**
* Finds and optionally removes unused CSS custom properties from theme.css.
*
* Usage:
* node scripts/clean-unused-theme-vars.js # dry run: report only
* node scripts/clean-unused-theme-vars.js --check # CI mode: exit 1 if any unused
* node scripts/clean-unused-theme-vars.js --remove # remove unused declarations
* node scripts/clean-unused-theme-vars.js --verbose # show all usage counts
*/
const fs = require("fs");
const path = require("path");
const REMOVE = process.argv.includes("--remove");
const CHECK = process.argv.includes("--check");
const VERBOSE =
process.argv.includes("--verbose") || process.argv.includes("-v");
const EDITOR_ROOT = path.join(__dirname, "..");
const THEME_CSS_PATH = path.join(EDITOR_ROOT, "src/core/styles/theme.css");
const SCAN_EXTENSIONS = new Set([
".ts",
".tsx",
".css",
".js",
".jsx",
".mts",
".mjs",
]);
const SKIP_DIRS = new Set([
"node_modules",
"dist",
".git",
"target",
"coverage",
".turbo",
]);
// ─── Parse declarations ───────────────────────────────────────────────────────
function parseDeclarations(content) {
const lines = content.split("\n");
// Map from varName -> array of 0-based line indices where it is declared
const declarations = new Map();
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/^\s*(--[\w-]+)\s*:/);
if (match) {
const name = match[1];
if (!declarations.has(name)) declarations.set(name, []);
declarations.get(name).push(i);
}
}
return declarations;
}
// ─── Scan for usages ─────────────────────────────────────────────────────────
const VAR_USAGE_RE = /var\((--[\w-]+)/g;
function collectUsagesInFile(filePath, used) {
let content;
try {
content = fs.readFileSync(filePath, "utf8");
} catch {
return;
}
VAR_USAGE_RE.lastIndex = 0;
let match;
while ((match = VAR_USAGE_RE.exec(content)) !== null) {
used.add(match[1]);
}
}
function scanDir(dir, used) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
scanDir(fullPath, used);
} else if (SCAN_EXTENSIONS.has(path.extname(entry.name))) {
collectUsagesInFile(fullPath, used);
}
}
}
// ─── Remove unused declarations ───────────────────────────────────────────────
function removeDeclarations(content, unusedNames) {
const lines = content.split("\n");
const filtered = [];
let skipping = false;
for (const line of lines) {
if (skipping) {
// Still consuming a multi-line value — stop when we hit the terminating semicolon
if (line.includes(";")) skipping = false;
continue;
}
const match = line.match(/^\s*(--[\w-]+)\s*:/);
if (match && unusedNames.has(match[1])) {
// If the value doesn't end on this line, skip subsequent lines too
if (!line.includes(";")) skipping = true;
continue;
}
filtered.push(line);
}
// Collapse runs of 3+ blank lines down to 2 (avoids gaps from removed blocks)
const collapsed = [];
let blankRun = 0;
for (const line of filtered) {
if (line.trim() === "") {
blankRun++;
if (blankRun <= 2) collapsed.push(line);
} else {
blankRun = 0;
collapsed.push(line);
}
}
return collapsed.join("\n");
}
// ─── Main ─────────────────────────────────────────────────────────────────────
function main() {
if (!fs.existsSync(THEME_CSS_PATH)) {
console.error("theme.css not found at:", THEME_CSS_PATH);
process.exit(1);
}
const themeCss = fs.readFileSync(THEME_CSS_PATH, "utf8");
const declarations = parseDeclarations(themeCss);
console.log(`Found ${declarations.size} declared CSS variables in theme.css`);
console.log("Scanning source files for usages...");
const used = new Set();
scanDir(EDITOR_ROOT, used);
const unused = new Set();
for (const name of declarations.keys()) {
if (!used.has(name)) unused.add(name);
}
if (VERBOSE) {
const allNames = [...declarations.keys()].sort();
for (const name of allNames) {
const status = used.has(name) ? "used " : "UNUSED";
console.log(` ${status} ${name}`);
}
console.log();
}
if (unused.size === 0) {
console.log("No unused CSS variables found.");
return;
}
const sortedUnused = [...unused].sort();
console.log(`\nUnused variables (${unused.size}):`);
for (const name of sortedUnused) {
const lineNums = declarations
.get(name)
.map((i) => i + 1)
.join(", ");
console.log(
` ${name} (line${declarations.get(name).length > 1 ? "s" : ""} ${lineNums})`,
);
}
if (CHECK) {
console.error(
"\ntheme.css has unused CSS variables. Run the following to fix:",
);
console.error("\n task frontend:theme:clean-unused-vars REMOVE=true\n");
process.exit(1);
}
if (!REMOVE) {
console.log(
"\nDry run — no changes written. Re-run with --remove to delete unused declarations.",
);
return;
}
const updated = removeDeclarations(themeCss, unused);
fs.writeFileSync(THEME_CSS_PATH, updated, "utf8");
// Count declarations removed
const declsRemoved = [...unused].reduce(
(sum, name) => sum + declarations.get(name).length,
0,
);
console.log(
`\nRemoved ${declsRemoved} declaration${declsRemoved !== 1 ? "s" : ""} (${unused.size} unique variable${unused.size !== 1 ? "s" : ""}) from theme.css`,
);
}
main();
@@ -0,0 +1,53 @@
/**
* Core stubs for the right-rail Agents UI.
*
* The real implementations live in {@code proprietary/components/agents/AgentsPanel.tsx}
* and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
* build is active. Core builds render nothing, so the right rail collapses to the
* tool list unchanged.
*/
/** Whether the right rail should reserve space for agents UI. False in core. */
export function useAgentsEnabled(): boolean {
return false;
}
/**
* Whether the agent chat overlay is currently open. Core builds have no chat,
* so this always returns false. Proprietary builds bridge to the ChatContext.
* Used by {@code RightSidebar} so the fullscreen tool picker can yield to the
* chat overlay just like it yields to a selected tool.
*/
export function useAgentChatOpen(): boolean {
return false;
}
/** Inline "Agents" section rendered above the tool list in {@code ToolPicker}. */
export function AgentsSection() {
return null;
}
/**
* Icon-only agent button rendered in the collapsed (minimised) right rail.
* Returns null in core; proprietary renders the Stirling agent shortcut.
*/
export function AgentsCollapsedButton(_props: { onExpand: () => void }) {
return null;
}
/**
* Full-rail chat overlay rendered inside {@code ToolPanel}. Covers the panel
* (including the search bar) when an agent conversation is active.
*/
export function AgentsChatOverlay() {
return null;
}
/**
* Agents card rendered inside the fullscreen tool picker. Matches the visual
* language of the fullscreen category cards (gradient border, title, items).
* Returns null in core; proprietary renders the Stirling agent.
*/
export function AgentsFullscreenSection() {
return null;
}
@@ -75,7 +75,7 @@
box-shadow: var(--shadow-md);
overflow: hidden;
position: relative;
background: var(--bg-surface);
background: white;
transition:
box-shadow 0.15s ease,
transform 0.2s ease;
@@ -1,4 +1,4 @@
import { createContext, useContext, ReactNode } from "react";
import { createContext, useContext, ReactNode, useEffect } from "react";
import { MantineProvider } from "@mantine/core";
import { useRainbowTheme } from "@app/hooks/useRainbowTheme";
import { mantineTheme } from "@app/theme/mantineTheme";
@@ -36,9 +36,20 @@ interface RainbowThemeProviderProps {
export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
const rainbowTheme = useRainbowTheme();
// Determine the Mantine color scheme
// Determine the Mantine color scheme (midnight and rainbow both use Mantine dark)
const mantineColorScheme =
rainbowTheme.themeMode === "rainbow" ? "dark" : rainbowTheme.themeMode;
rainbowTheme.themeMode === "rainbow" ||
rainbowTheme.themeMode === "midnight"
? "dark"
: rainbowTheme.themeMode;
// Apply data-color-scheme attribute to html element for CSS midnight overrides
useEffect(() => {
document.documentElement.setAttribute(
"data-color-scheme",
rainbowTheme.themeMode,
);
}, [rainbowTheme.themeMode]);
return (
<RainbowThemeContext.Provider value={rainbowTheme}>
@@ -49,7 +49,7 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
const { t } = useTranslation();
const { preferences, updatePreference } = usePreferences();
const { config } = useAppConfig();
const { toggleTheme, themeMode } = useRainbowThemeContext();
const { themeMode } = useRainbowThemeContext();
const [fileLimitInput, setFileLimitInput] = useState<number | string>(
preferences.autoUnzipFileLimit,
);
@@ -380,10 +380,10 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
</Text>
</div>
<SegmentedControl
value={themeMode === "rainbow" ? "dark" : themeMode}
onChange={(val) => {
if ((themeMode === "dark") !== (val === "dark")) toggleTheme();
}}
value={themeMode === "rainbow" ? "dark" : (themeMode as string)}
onChange={(val) =>
updatePreference("theme", val as "light" | "dark" | "midnight")
}
data={[
{
label: t("settings.general.themeLight", "Light"),
@@ -393,6 +393,10 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
label: t("settings.general.themeDark", "Dark"),
value: "dark",
},
{
label: t("settings.general.themeMidnight", "Midnight"),
value: "midnight",
},
]}
/>
</div>
@@ -0,0 +1,102 @@
import { useEffect, useMemo } from "react";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { usePreferences } from "@app/contexts/PreferencesContext";
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
import {
AgentsFullscreenSection,
useAgentChatOpen,
useAgentsEnabled,
} from "@app/components/agents/AgentsPanel";
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
import { ToolId } from "@app/types/toolId";
import type { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
/** Derives whether the fullscreen tool picker is currently expanded. */
export function useIsFullscreenExpanded(): boolean {
const { toolPanelMode, leftPanelView, readerMode } = useToolWorkflow();
const isMobile = useIsMobile();
const agentChatOpen = useAgentChatOpen();
return (
toolPanelMode === "fullscreen" &&
leftPanelView === "toolPicker" &&
!isMobile &&
!readerMode &&
!agentChatOpen
);
}
interface FullscreenToolPanelProps {
geometry: ToolPanelGeometry | null;
}
/**
* Self-contained fullscreen tool picker. Renders null when inactive, and takes
* over the right rail (via FullscreenToolSurface) when fullscreen mode is on.
* Geometry is computed by the parent (RightSidebar) so its useLayoutEffect runs
* after the ref div is committed, ensuring toolPanelRef.current is always set.
*/
export function FullscreenToolPanel({ geometry }: FullscreenToolPanelProps) {
const {
toolPanelMode,
setToolPanelMode,
leftPanelView,
readerMode,
searchQuery,
setSearchQuery,
filteredTools,
toolRegistry,
selectedToolKey,
handleToolSelect,
} = useToolWorkflow();
const isMobile = useIsMobile();
const agentsEnabled = useAgentsEnabled();
const agentChatOpen = useAgentChatOpen();
const { setAllButtonsDisabled } = useWorkbenchBar();
const { preferences, updatePreference } = usePreferences();
const fullscreenExpanded =
toolPanelMode === "fullscreen" &&
leftPanelView === "toolPicker" &&
!isMobile &&
!readerMode &&
!agentChatOpen;
useEffect(() => {
setAllButtonsDisabled(fullscreenExpanded);
}, [fullscreenExpanded, setAllButtonsDisabled]);
const matchedTextMap = useMemo(() => {
const map = new Map<string, string>();
filteredTools.forEach(({ item: [id], matchedText }) => {
if (matchedText) map.set(id, matchedText);
});
return map;
}, [filteredTools]);
if (!fullscreenExpanded) return null;
return (
<FullscreenToolSurface
searchQuery={searchQuery}
toolRegistry={toolRegistry}
filteredTools={filteredTools}
selectedToolKey={selectedToolKey}
showDescriptions={preferences.showLegacyToolDescriptions}
matchedTextMap={matchedTextMap}
onSearchChange={setSearchQuery}
onSelect={(id: ToolId) => handleToolSelect(id)}
onToggleDescriptions={() =>
updatePreference(
"showLegacyToolDescriptions",
!preferences.showLegacyToolDescriptions,
)
}
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
geometry={geometry}
agentsSlot={
agentsEnabled && !searchQuery ? <AgentsFullscreenSection /> : null
}
/>
);
}
@@ -1,4 +1,5 @@
import { useRef } from "react";
import { createPortal } from "react-dom";
import { ScrollArea, Switch } from "@mantine/core";
import { useTranslation } from "react-i18next";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
@@ -6,8 +7,6 @@ import FullscreenToolList from "@app/components/tools/FullscreenToolList";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import { ToolId } from "@app/types/toolId";
import { useFocusTrap } from "@app/hooks/useFocusTrap";
import { LogoIcon } from "@app/components/shared/LogoIcon";
import { Wordmark } from "@app/components/shared/Wordmark";
import "@app/components/tools/ToolPanel.css";
import { ToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
@@ -26,6 +25,8 @@ interface FullscreenToolSurfaceProps {
onToggleDescriptions: () => void;
onExitFullscreenMode: () => void;
geometry: ToolPanelGeometry | null;
/** Optional agents block rendered above the tool list. */
agentsSlot?: React.ReactNode;
}
const FullscreenToolSurface = ({
@@ -40,6 +41,7 @@ const FullscreenToolSurface = ({
onToggleDescriptions,
onExitFullscreenMode: _onExitFullscreenMode,
geometry,
agentsSlot,
}: FullscreenToolSurfaceProps) => {
const { t } = useTranslation();
const surfaceRef = useRef<HTMLDivElement>(null);
@@ -47,18 +49,16 @@ const FullscreenToolSurface = ({
// Enable focus trap when surface is active
useFocusTrap(surfaceRef, true);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
if (!geometry) return null;
const style = geometry
? {
left: `${geometry.left}px`,
top: `${geometry.top}px`,
width: `${geometry.width}px`,
height: `${geometry.height}px`,
}
: undefined;
const style = {
left: `${geometry.left}px`,
top: `${geometry.top}px`,
width: `${geometry.width}px`,
height: `${geometry.height}px`,
};
return (
const surface = (
<div
className="tool-panel__fullscreen-surface"
style={style}
@@ -70,16 +70,6 @@ const FullscreenToolSurface = ({
data-tour="tool-panel"
>
<div ref={surfaceRef} className="tool-panel__fullscreen-surface-inner">
<header className="tool-panel__fullscreen-header">
<div className="tool-panel__fullscreen-brand">
<LogoIcon className="tool-panel__fullscreen-brand-icon" />
<Wordmark
alt={brandAltText}
className="tool-panel__fullscreen-brand-text"
/>
</div>
</header>
<div className="tool-panel__fullscreen-controls">
<ToolSearch
value={searchQuery}
@@ -102,6 +92,9 @@ const FullscreenToolSurface = ({
className="tool-panel__fullscreen-scroll"
offsetScrollbars
>
{agentsSlot && (
<div className="tool-panel__fullscreen-agents">{agentsSlot}</div>
)}
<FullscreenToolList
filteredTools={filteredTools}
searchQuery={searchQuery}
@@ -115,6 +108,9 @@ const FullscreenToolSurface = ({
</div>
</div>
);
if (typeof document === "undefined") return surface;
return createPortal(surface, document.body);
};
export default FullscreenToolSurface;
@@ -0,0 +1,333 @@
import { useMemo, useState } from "react";
import { ActionIcon } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import rainbowStyles from "@app/styles/rainbow.module.css";
import { useIsMobile } from "@app/hooks/useIsMobile";
import ToolPanel from "@app/components/tools/ToolPanel";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import {
AgentsChatOverlay,
AgentsCollapsedButton,
AgentsSection,
useAgentsEnabled,
} from "@app/components/agents/AgentsPanel";
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
import { useToolSections } from "@app/hooks/useToolSections";
import type { SubcategoryGroup } from "@app/hooks/useToolSections";
import { ToolIcon } from "@app/components/shared/ToolIcon";
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
import { withViewTransition } from "@app/utils/viewTransition";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import CloseIcon from "@mui/icons-material/Close";
import { ToolId } from "@app/types/toolId";
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import {
FullscreenToolPanel,
useIsFullscreenExpanded,
} from "@app/components/tools/FullscreenToolPanel";
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
import "@app/components/tools/ToolPanel.css";
/**
* Right-side rail wrapping the tool panel.
*
* Owns the rail-level concerns: collapse/expand chrome, the AGENTS top label,
* the collapsed strip (agent button + favourite/recommended icon shortcuts),
* and the agents chat overlay. Fullscreen takeover lives in FullscreenToolPanel.
*/
export default function RightSidebar() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef, quickAccessRef } = sidebarRefs;
const isMobile = useIsMobile();
const {
leftPanelView,
isPanelVisible,
searchQuery,
filteredTools,
toolRegistry,
setSearchQuery,
selectedToolKey,
handleToolSelect,
handleBackToTools,
setLeftPanelView,
setReaderMode,
setSidebarsVisible,
sidebarsVisible,
readerMode,
favoriteTools,
} = useToolWorkflow();
const agentsEnabled = useAgentsEnabled();
const fullscreenExpanded = useIsFullscreenExpanded();
const fullscreenGeometry = useToolPanelGeometry({
enabled: fullscreenExpanded,
toolPanelRef,
quickAccessRef,
});
const handleExpand = () => {
withViewTransition(() => {
if (readerMode) setReaderMode(false);
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
if (!sidebarsVisible) setSidebarsVisible(true);
});
};
const handleCollapse = () => {
withViewTransition(() => setLeftPanelView("hidden"));
};
const [allToolsView, setAllToolsView] = useState(false);
const handleShowAllTools = () => {
withViewTransition(() => setAllToolsView(true));
};
const handleBackToDefault = () => {
withViewTransition(() => {
setAllToolsView(false);
setSearchQuery("");
});
};
// The header shows [back] [search] when we have somewhere to go back to —
// i.e. the user is in a specific tool, or already in the all-tools/search view.
const inToolView = leftPanelView !== "toolPicker";
// Show X (close) button only when there's somewhere to go back to.
const showCloseButton = inToolView || allToolsView;
// Show search input whenever there's a close button, or when agents are off and
// we're in the default tool-picker view (search filters the full list inline).
const showHeaderSearch =
showCloseButton || (!agentsEnabled && leftPanelView === "toolPicker");
const handleHeaderBack = () => {
if (inToolView) {
withViewTransition(() => handleBackToTools());
} else {
handleBackToDefault();
}
};
const handleToolSelectWithTransition = (id: ToolId) => {
withViewTransition(() => handleToolSelect(id));
};
// Typing in the header search while inside a tool exits the tool and lifts the
// panel into the all-tools view so the user immediately sees search results.
const handleHeaderSearchChange = (value: string) => {
if (inToolView) {
withViewTransition(() => {
handleBackToTools();
setAllToolsView(true);
setSearchQuery(value);
});
return;
}
setSearchQuery(value);
};
const activeTool: ToolRegistryEntry | null =
inToolView && selectedToolKey
? (toolRegistry[selectedToolKey as ToolId] ?? null)
: null;
// Agents header + section is hidden when:
// - the AI engine is off, or
// - the user is in the all-tools view, or
// - a specific tool is being rendered (leftPanelView ≠ "toolPicker").
const showAgents =
agentsEnabled && !allToolsView && leftPanelView === "toolPicker";
const computedWidth = () => {
if (isMobile) return "100%";
if (!isPanelVisible) return "3.5rem";
return "18.5rem";
};
// Collapsed rail: show favourites + recommended tools as icons.
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
const { sections: collapsedSections } = useToolSections(filteredTools);
const collapsedQuickSection = useMemo(
() => collapsedSections.find((s) => s.key === "quick"),
[collapsedSections],
);
const collapsedRecommendedItems = useMemo(() => {
if (!collapsedQuickSection) return [];
const items: Array<{ id: ToolId; tool: ToolRegistryEntry }> = [];
collapsedQuickSection.subcategories.forEach((sc: SubcategoryGroup) =>
sc.tools.forEach((entry) =>
items.push({ id: entry.id as ToolId, tool: entry.tool }),
),
);
return items;
}, [collapsedQuickSection]);
const collapsedRailItems = useMemo(() => {
const map = new Map<ToolId, ToolRegistryEntry>();
favoriteToolItems.forEach(({ id, tool }) => map.set(id, tool));
collapsedRecommendedItems.forEach(({ id, tool }) => {
if (!map.has(id)) map.set(id, tool);
});
return Array.from(map, ([id, tool]) => ({ id, tool }));
}, [favoriteToolItems, collapsedRecommendedItems]);
return (
<div
ref={toolPanelRef}
data-sidebar="tool-panel"
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
isRainbowMode ? rainbowStyles.rainbowPaper : ""
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
style={{
width: computedWidth(),
padding: "0",
}}
>
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
<div className="tool-panel__collapsed-strip">
<div className="tool-panel__collapsed-top">
<ActionIcon
variant="outline"
color="gray.4"
radius="xl"
size="md"
className="tool-panel__expand-btn"
onClick={handleExpand}
aria-label={t("toolPanel.expand", "Expand panel")}
>
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
<AgentsCollapsedButton onExpand={handleExpand} />
</div>
<div className="tool-panel__collapsed-divider" />
<div className="tool-panel__collapsed-tools">
{collapsedRailItems.map(({ id, tool }) => (
<AppTooltip
key={id}
content={tool.name}
position="left"
arrow
delay={300}
>
<button
type="button"
className="tool-panel__collapsed-tool-btn"
data-selected={selectedToolKey === id}
onClick={() => {
handleExpand();
handleToolSelectWithTransition(id);
}}
aria-label={tool.name}
>
<ToolIcon icon={tool.icon} marginRight="0" />
</button>
</AppTooltip>
))}
</div>
</div>
)}
{!fullscreenExpanded && isPanelVisible && (
<div
/* Fixed width matches the expanded panel width so the inner content is
laid out at its final size from the moment it mounts. The outer
.tool-panel clips it (overflow-hidden) while it animates from the
collapsed 3.5rem width — text/icons stay put and just come into view
instead of jiggling as space becomes available. */
style={{
opacity: 1,
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
height: "100%",
width: isMobile ? "100%" : "18.5rem",
flexShrink: 0,
display: "flex",
flexDirection: "column",
}}
>
<div className="tool-panel__compact-header">
{activeTool ? (
<div
className="tool-panel__active-tool-pill"
aria-label={activeTool.name}
>
<span className="tool-panel__active-tool-pill-icon">
<ToolIcon
icon={activeTool.icon}
marginRight="0"
color="var(--mantine-color-blue-filled)"
/>
</span>
<span className="tool-panel__active-tool-pill-label">
{activeTool.name}
</span>
</div>
) : showHeaderSearch ? (
<div className="tool-panel__compact-header-search">
<ToolSearch
value={searchQuery}
onChange={handleHeaderSearchChange}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={allToolsView && !inToolView}
/>
</div>
) : (
showAgents && (
<span className="tool-panel__section-label">
{t("agents.section_title", "Agents")}
</span>
)
)}
{showCloseButton ? (
<ActionIcon
variant="subtle"
color="gray"
radius="xl"
size="md"
onClick={handleHeaderBack}
aria-label={
inToolView
? t("toolPanel.backToAllTools", "Back to all tools")
: t("toolPanel.goBack", "Go back")
}
className="tool-panel__expand-btn"
>
<CloseIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
) : (
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn"
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
)}
</div>
{showAgents && <AgentsSection />}
<ToolPanel
allToolsView={allToolsView}
onShowAllTools={handleShowAllTools}
onToolSelect={handleToolSelectWithTransition}
compact={agentsEnabled && !allToolsView}
/>
<AgentsChatOverlay />
</div>
)}
<FullscreenToolPanel geometry={fullscreenGeometry} />
</div>
);
}
@@ -129,17 +129,70 @@
transition:
width 0.3s ease,
max-width 0.3s ease;
view-transition-name: tool-rail;
}
.tool-panel__collapsed-strip {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
align-items: stretch;
padding-top: 10px;
gap: 8px;
height: 100%;
width: 100%;
min-height: 0;
}
.tool-panel__collapsed-top {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 0 0 10px;
}
.tool-panel__collapsed-divider {
height: 1px;
background: var(--border-subtle);
margin: 0 0.5rem 8px;
}
.tool-panel__collapsed-tools {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 0 0 12px;
overflow-y: auto;
overflow-x: hidden;
flex: 1 1 auto;
min-height: 0;
}
.tool-panel__collapsed-tool-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border: 1px solid transparent;
border-radius: 0.5rem;
background: transparent;
color: var(--tools-text-and-icon-color);
cursor: pointer;
transition:
background 120ms ease-out,
border-color 120ms ease-out;
padding: 0;
flex-shrink: 0;
}
.tool-panel__collapsed-tool-btn:hover {
background: var(--mantine-color-default-hover);
}
.tool-panel__collapsed-tool-btn[data-selected="true"] {
background: var(--mantine-color-default-hover);
border-color: var(--mantine-color-default-border);
}
.tool-panel__expand-btn {
@@ -174,6 +227,11 @@
flex-shrink: 0;
}
.tool-panel__back-bar {
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle) !important;
}
.tool-panel--fullscreen-active {
overflow: visible !important;
}
@@ -191,6 +249,133 @@
flex: 1 1 auto;
}
.tool-panel__compact-header {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 52px;
padding: 0.5rem 0.75rem;
box-sizing: border-box;
}
.tool-panel__compact-header .tool-panel__expand-btn {
margin-left: auto;
}
.tool-panel__compact-header-search {
flex: 1 1 auto;
min-width: 0;
}
.tool-panel__compact-header-search .search-input-container {
width: 100%;
}
.tool-panel__active-tool-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex: 1 1 auto;
min-width: 0;
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
border: 1px solid var(--border-subtle);
border-radius: 9999px;
background: var(--mantine-color-body);
}
.tool-panel__active-tool-pill-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 9999px;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
flex-shrink: 0;
}
[data-mantine-color-scheme="dark"] .tool-panel__active-tool-pill {
background: transparent;
border-color: var(--border-subtle);
}
[data-mantine-color-scheme="dark"] .tool-panel__active-tool-pill-icon {
background: color-mix(
in srgb,
var(--mantine-color-blue-filled) 18%,
transparent
);
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
}
/* The inner .tool-button-icon wrapper carries its own transform/margin; reset
them so the icon glyph sits dead-centre in the circular background. */
.tool-panel__active-tool-pill-icon .tool-button-icon {
margin: 0 !important;
transform: none !important;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.tool-panel__active-tool-pill-icon svg {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
.tool-panel__active-tool-pill-label {
font-size: 0.9rem;
font-weight: 600;
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-primary);
}
::view-transition-old(tool-rail) {
animation: vt-rail-fade-out 180ms ease-out forwards;
}
::view-transition-new(tool-rail) {
animation: vt-rail-fade-in 220ms ease-in forwards;
}
@keyframes vt-rail-fade-out {
to {
opacity: 0;
}
}
@keyframes vt-rail-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(tool-rail),
::view-transition-new(tool-rail) {
animation-duration: 1ms !important;
}
}
.tool-panel__section-label {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
line-height: 1;
}
.tool-panel__mode-toggle {
transition: transform 0.2s ease;
}
@@ -652,6 +837,33 @@
gap: 0.75rem;
}
/* Agents card lives in its own column above the tool grid. It uses the same
.tool-panel__fullscreen-group base styles but with a colourful gradient
border so it stands out as a distinct surface (and doesn't blend into the
neighbouring category cards). */
.tool-panel__fullscreen-agents {
padding: 1.5rem 1.75rem 0;
}
.tool-panel__fullscreen-group--agents {
position: relative;
background:
linear-gradient(var(--fullscreen-bg-group), var(--fullscreen-bg-group))
padding-box,
linear-gradient(
135deg,
var(--mantine-color-blue-6) 0%,
var(--mantine-color-violet-6) 45%,
var(--mantine-color-pink-6) 100%
)
border-box;
border: 1.5px solid transparent;
}
.tool-panel__fullscreen-section-icon--agents {
color: var(--mantine-color-blue-6);
}
@keyframes tool-panel-fullscreen-slide-in {
from {
transform: translateX(6%) scaleX(0.85);
@@ -1,303 +1,85 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider";
import {
useToolWorkflow,
useToolWorkflowActions,
} from "@app/contexts/ToolWorkflowContext";
import { usePreferences } from "@app/contexts/PreferencesContext";
import { ScrollArea } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import ToolPicker from "@app/components/tools/ToolPicker";
import SearchResults from "@app/components/tools/SearchResults";
import ToolRenderer from "@app/components/tools/ToolRenderer";
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
import { useSidebarContext } from "@app/contexts/SidebarContext";
import rainbowStyles from "@app/styles/rainbow.module.css";
import { ActionIcon, Button, ScrollArea } from "@mantine/core";
import { ToolId } from "@app/types/toolId";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { useTranslation } from "react-i18next";
import FullscreenToolSurface from "@app/components/tools/FullscreenToolSurface";
import { ToolPanelViewerBar } from "@app/components/tools/ToolPanelViewerBar";
import { useToolPanelGeometry } from "@app/hooks/tools/useToolPanelGeometry";
import { useWorkbenchBar } from "@app/contexts/WorkbenchBarContext";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SearchIcon from "@mui/icons-material/Search";
import "@app/components/tools/ToolPanel.css";
import { ToolId } from "@app/types/toolId";
// No props needed - component uses context
interface ToolPanelProps {
/** Whether to expand into the full categorised tools view (with search). */
allToolsView: boolean;
/** Trigger to enter the all-tools view (from the "View all tools" button). */
onShowAllTools: () => void;
/**
* Tool-selection handler injected by {@code RightSidebar} so the click can
* be wrapped in a View Transition (the tool card morphs into the header
* pill). Falls back to the workflow context's handler when not provided.
*/
onToolSelect?: (id: ToolId) => void;
/** Whether to render the compact (favourites + recommended only) view. */
compact?: boolean;
}
export default function ToolPanel() {
/** Tool list and renderer for the right rail; rail chrome lives in RightSidebar. */
export default function ToolPanel({
allToolsView,
onShowAllTools,
onToolSelect,
compact: compactProp,
}: ToolPanelProps) {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef, quickAccessRef } = sidebarRefs;
const isMobile = useIsMobile();
const {
leftPanelView,
isPanelVisible,
searchQuery,
filteredTools,
toolRegistry,
setSearchQuery,
selectedToolKey,
toolPanelMode,
sidebarsVisible,
readerMode,
} = useToolWorkflow();
const {
handleToolSelect,
handleBackToTools,
setPreviewFile,
setToolPanelMode,
setLeftPanelView,
setReaderMode,
setSidebarsVisible,
} = useToolWorkflowActions();
const { setAllButtonsDisabled } = useWorkbenchBar();
const { preferences, updatePreference } = usePreferences();
const isFullscreenMode = toolPanelMode === "fullscreen";
const toolPickerVisible = !readerMode;
const fullscreenExpanded =
isFullscreenMode &&
leftPanelView === "toolPicker" &&
!isMobile &&
toolPickerVisible;
// Disable workbench bar buttons when fullscreen mode is active
useEffect(() => {
setAllButtonsDisabled(fullscreenExpanded);
}, [fullscreenExpanded, setAllButtonsDisabled]);
const fullscreenGeometry = useToolPanelGeometry({
enabled: fullscreenExpanded,
toolPanelRef,
quickAccessRef,
});
const handleExpand = () => {
if (readerMode) setReaderMode(false);
if (leftPanelView === "hidden") setLeftPanelView("toolPicker");
if (!sidebarsVisible) setSidebarsVisible(true);
};
const handleCollapse = () => {
setLeftPanelView("hidden");
};
const [focusSearch, setFocusSearch] = useState(false);
const focusSearchOnNextOpen = useRef(false);
const handleExpandAndSearch = () => {
focusSearchOnNextOpen.current = true;
handleExpand();
};
// Once the panel becomes visible, consume the focus-search request
useEffect(() => {
if (isPanelVisible && focusSearchOnNextOpen.current) {
focusSearchOnNextOpen.current = false;
setFocusSearch(true);
// Reset after one render so autoFocus doesn't re-fire on subsequent renders
const id = setTimeout(() => setFocusSearch(false), 100);
return () => clearTimeout(id);
}
}, [isPanelVisible]);
const computedWidth = () => {
if (isMobile) {
return "100%";
}
if (!isPanelVisible) {
return "3.5rem";
}
return "18.5rem";
};
const handleSelect = useCallback(
(id: string) => handleToolSelect(id as ToolId),
[handleToolSelect],
);
const matchedTextMap = useMemo(() => {
const map = new Map<string, string>();
filteredTools.forEach(({ item: [id], matchedText }) => {
if (matchedText) {
map.set(id, matchedText);
}
});
return map;
}, [filteredTools]);
} = useToolWorkflow();
const selectTool = onToolSelect ?? handleToolSelect;
return (
<div
ref={toolPanelRef}
data-sidebar="tool-panel"
data-tour={fullscreenExpanded ? undefined : "tool-panel"}
className={`tool-panel flex flex-col ${fullscreenExpanded ? "tool-panel--fullscreen-active" : "overflow-hidden"} bg-[var(--bg-toolbar)] border-l border-[var(--border-subtle)] transition-all duration-300 ease-out ${
isRainbowMode ? rainbowStyles.rainbowPaper : ""
} ${isMobile ? "h-full border-r-0" : "h-screen"} ${fullscreenExpanded ? "tool-panel--fullscreen" : ""}`}
style={{
width: computedWidth(),
padding: "0",
}}
>
{!fullscreenExpanded && !isPanelVisible && !isMobile && (
<div className="tool-panel__collapsed-strip">
<ActionIcon
variant="outline"
color="gray.4"
radius="xl"
size="md"
className="tool-panel__expand-btn"
onClick={handleExpand}
aria-label={t("toolPanel.expand", "Expand panel")}
>
<ChevronLeftIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
radius="md"
size="md"
className="tool-panel__collapsed-search-btn"
onClick={handleExpandAndSearch}
aria-label={t("toolPanel.search", "Search tools")}
style={{ marginTop: "8px" }}
>
<SearchIcon sx={{ fontSize: "1.25rem" }} />
</ActionIcon>
<>
{/* Viewer mode tools — annotate, redact, form fill */}
<ToolPanelViewerBar />
{allToolsView && searchQuery.trim().length > 0 ? (
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
onSelect={(id) => selectTool(id as ToolId)}
searchQuery={searchQuery}
/>
</div>
)}
{!fullscreenExpanded && isPanelVisible && (
<div
style={{
opacity: 1,
transition: "opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)",
height: "100%",
display: "flex",
flexDirection: "column",
}}
>
{/* Viewer mode tools — annotate, redact, form fill */}
<ToolPanelViewerBar />
<div
className="tool-panel__search-row"
style={{
backgroundColor: "transparent",
borderBottom: "1px solid var(--border-subtle)",
}}
>
<ToolSearch
value={searchQuery}
onChange={setSearchQuery}
toolRegistry={toolRegistry}
mode="filter"
autoFocus={focusSearch}
/>
<ActionIcon
variant="outline"
radius="xl"
size="md"
onClick={handleCollapse}
aria-label={t("toolPanel.collapse", "Collapse panel")}
className="tool-panel__expand-btn"
style={{ flexShrink: 0 }}
>
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
</ActionIcon>
</div>
{searchQuery.trim().length > 0 ? (
<div className="flex-1 flex flex-col overflow-y-auto">
<SearchResults
filteredTools={filteredTools}
onSelect={handleSelect}
searchQuery={searchQuery}
/>
</div>
) : leftPanelView === "toolPicker" ? (
<div className="flex-1 flex flex-col overflow-auto">
<ToolPicker
) : leftPanelView === "toolPicker" ? (
<div className="flex-1 flex flex-col overflow-auto">
<ToolPicker
selectedToolKey={selectedToolKey}
onSelect={(id) => selectTool(id as ToolId)}
filteredTools={filteredTools}
isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)}
compact={compactProp ?? !allToolsView}
onShowAllTools={onShowAllTools}
/>
</div>
) : (
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea h="100%">
{selectedToolKey ? (
<ToolRenderer
selectedToolKey={selectedToolKey}
onSelect={handleSelect}
filteredTools={filteredTools}
isSearching={Boolean(
searchQuery && searchQuery.trim().length > 0,
)}
onPreviewFile={setPreviewFile}
/>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
<div
style={{
borderBottom: "1px solid var(--border-subtle)",
flexShrink: 0,
}}
>
<Button
variant="light"
color="blue"
size="sm"
fullWidth
radius={0}
leftSection={<ArrowBackIcon sx={{ fontSize: "0.9rem" }} />}
onClick={handleBackToTools}
aria-label={t("toolPanel.backToTools", "Back to tools")}
styles={{ root: { justifyContent: "flex-start" } }}
>
{t("toolPanel.backToTools", "Back to tools")}
</Button>
) : (
<div className="tool-panel__placeholder">
{t("toolPanel.placeholder", "Choose a tool to get started")}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea h="100%">
{selectedToolKey ? (
<ToolRenderer
selectedToolKey={selectedToolKey}
onPreviewFile={setPreviewFile}
/>
) : (
<div className="tool-panel__placeholder">
{t(
"toolPanel.placeholder",
"Choose a tool to get started",
)}
</div>
)}
</ScrollArea>
</div>
</div>
)}
)}
</ScrollArea>
</div>
)}
{fullscreenExpanded && (
<FullscreenToolSurface
searchQuery={searchQuery}
toolRegistry={toolRegistry}
filteredTools={filteredTools}
selectedToolKey={selectedToolKey}
showDescriptions={preferences.showLegacyToolDescriptions}
matchedTextMap={matchedTextMap}
onSearchChange={setSearchQuery}
onSelect={(id: ToolId) => handleToolSelect(id)}
onToggleDescriptions={() =>
updatePreference(
"showLegacyToolDescriptions",
!preferences.showLegacyToolDescriptions,
)
}
onExitFullscreenMode={() => setToolPanelMode("sidebar")}
geometry={fullscreenGeometry}
/>
)}
</div>
</>
);
}
@@ -1,5 +1,5 @@
import React, { memo, useMemo, useRef } from "react";
import { Box, Stack } from "@mantine/core";
import React, { useMemo, useRef } from "react";
import { Box, Button, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import "@app/components/tools/toolPicker/ToolPicker.css";
@@ -22,6 +22,10 @@ interface ToolPickerProps {
matchedText?: string;
}>;
isSearching?: boolean;
/** Compact "resting" view: favourites + recommended only, with a button to expand. */
compact?: boolean;
/** Called when the user clicks "View all tools" in compact mode. */
onShowAllTools?: () => void;
}
const EMPTY_FILTERED_TOOLS: ToolPickerProps["filteredTools"] = [];
@@ -57,6 +61,8 @@ const ToolPicker = ({
onSelect,
filteredTools,
isSearching = false,
compact = false,
onShowAllTools,
}: ToolPickerProps) => {
const { t } = useTranslation();
@@ -118,9 +124,60 @@ const ToolPicker = ({
)
)}
</Stack>
) : compact ? (
/* Resting state: flat list of pinned + recommended only. */
<Box className="tool-picker__compact">
<div style={HEADER_TEXT_STYLE}>
{t("toolPanel.toolsHeader", "Tools")}
</div>
{favoriteToolItems.length === 0 && recommendedItems.length === 0 ? (
<NoToolsFound />
) : (
<div className="tool-picker__compact-list">
{favoriteToolItems.map(({ id, tool }) => (
<ToolButton
key={`fav-${id}`}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
showDescription
/>
))}
{recommendedItems
.filter(
({ id }) => !favoriteToolItems.some((fav) => fav.id === id),
)
.map(({ id, tool }) => (
<ToolButton
key={`rec-${id}`}
id={id as ToolId}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
showDescription
/>
))}
</div>
)}
{onShowAllTools && (
<Button
variant="subtle"
size="sm"
fullWidth
onClick={onShowAllTools}
className="tool-picker__view-all"
aria-label={t("toolPanel.viewAllTools", "View all tools")}
>
{t("toolPanel.viewAllTools", "View all tools")}
</Button>
)}
</Box>
) : (
<>
{/* Flat list: favorites and recommended first, then all subcategories */}
{/* All-tools view: favourites + recommended + all subcategories. */}
<Stack p="sm" gap="xs">
{favoriteToolItems.length > 0 && (
<Box w="100%">
@@ -182,7 +239,6 @@ const ToolPicker = ({
{!quickSection && !allSection && <NoToolsFound />}
{/* bottom spacer to allow scrolling past the last row */}
<div aria-hidden style={{ height: 200 }} />
</>
)}
@@ -192,4 +248,4 @@ const ToolPicker = ({
);
};
export default memo(ToolPicker);
export default ToolPicker;
@@ -32,6 +32,7 @@ interface ToolButtonProps {
disableNavigation?: boolean;
matchedSynonym?: string;
hasStars?: boolean;
showDescription?: boolean;
/** Called when an unavailable tool is clicked; if provided, overrides the default no-op */
onUnavailableClick?: () => void;
}
@@ -44,6 +45,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
disableNavigation = false,
matchedSynonym,
hasStars = false,
showDescription = false,
onUnavailableClick,
}) => {
const { t } = useTranslation();
@@ -183,6 +185,14 @@ const ToolButton: React.FC<ToolButtonProps> = ({
)}
{usesCloud && !visuallyUnavailable && <CloudBadge />}
</div>
{showDescription && tool.description && (
<span
className="tool-button__description"
style={{ opacity: visuallyUnavailable ? 0.25 : 1 }}
>
{tool.description}
</span>
)}
{matchedSynonym && (
<span
style={{
@@ -205,8 +215,8 @@ const ToolButton: React.FC<ToolButtonProps> = ({
handleUnlessSpecialClick(e, () => handleClick(id));
};
const selectedStyles = isSelected
? { backgroundColor: "#EAEAEA", color: "var(--tools-text-and-icon-color)" }
const selectedBg = isSelected
? { backgroundColor: "var(--tool-button-selected-bg)" }
: {};
const buttonElement = navProps ? (
@@ -227,7 +237,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
overflow: "visible",
...selectedStyles,
...selectedBg,
},
label: { overflow: "visible" },
}}
@@ -254,7 +264,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
overflow: "visible",
...selectedStyles,
...selectedBg,
},
label: { overflow: "visible" },
}}
@@ -279,7 +289,6 @@ const ToolButton: React.FC<ToolButtonProps> = ({
color: "var(--tools-text-and-icon-color)",
cursor: visuallyUnavailable ? "not-allowed" : undefined,
overflow: "visible",
...selectedStyles,
},
label: { overflow: "visible" },
}}
@@ -55,6 +55,15 @@
flex: 1 1 auto;
}
/* Selected tool highlight — theme-aware via CSS variable */
:root {
--tool-button-selected-bg: var(--mantine-color-gray-2);
}
[data-mantine-color-scheme="dark"] {
--tool-button-selected-bg: var(--mantine-color-dark-4);
}
/* Compact tool buttons */
.tool-button {
font-size: 0.875rem;
@@ -96,3 +105,37 @@
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.tool-button__description {
font-size: 0.75rem;
color: var(--mantine-color-dimmed);
line-height: 1.35;
margin-top: 0.15rem;
white-space: normal;
text-align: left;
}
/* Resting / compact tool list — taller rows, room for description. */
.tool-picker__compact {
padding: 0.5rem var(--mantine-spacing-sm) var(--mantine-spacing-sm);
}
.tool-picker__compact .tool-button {
padding-top: 0.65rem;
padding-bottom: 0.65rem;
height: auto;
}
.tool-picker__compact .tool-button .mantine-Button-label {
align-items: center;
}
.tool-picker__compact-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.tool-picker__view-all {
margin-top: 0.75rem;
}
@@ -17,17 +17,13 @@ function CopyButton({ text }: { text: string }) {
position: "absolute",
top: 8,
right: 8,
background: copied
? "var(--mantine-color-green-0)"
: "var(--mantine-color-gray-0)",
border: "1px solid var(--mantine-color-gray-3)",
background: copied ? "var(--color-green-100)" : "var(--bg-muted)",
border: "1px solid var(--border-default)",
borderRadius: 3,
cursor: "pointer",
fontSize: "0.7em",
padding: "2px 8px",
color: copied
? "var(--mantine-color-green-7)"
: "var(--mantine-color-gray-7)",
color: copied ? "var(--color-green-700)" : "var(--text-secondary)",
}}
>
{copied ? "✓ Copied" : "Copy"}
@@ -36,6 +32,183 @@ function CopyButton({ text }: { text: string }) {
}
const components: Components = {
h1: ({ children }) => (
<h1
style={{
color: "var(--text-primary)",
borderBottom: "1px solid var(--border-default)",
paddingBottom: "0.3em",
marginTop: "1.2em",
marginBottom: "0.6em",
}}
>
{children}
</h1>
),
h2: ({ children }) => (
<h2
style={{
color: "var(--text-primary)",
borderBottom: "1px solid var(--border-subtle)",
paddingBottom: "0.2em",
marginTop: "1.1em",
marginBottom: "0.5em",
}}
>
{children}
</h2>
),
h3: ({ children }) => (
<h3
style={{
color: "var(--text-primary)",
marginTop: "1em",
marginBottom: "0.4em",
}}
>
{children}
</h3>
),
h4: ({ children }) => (
<h4
style={{
color: "var(--text-primary)",
marginTop: "0.8em",
marginBottom: "0.3em",
}}
>
{children}
</h4>
),
h5: ({ children }) => (
<h5
style={{
color: "var(--text-secondary)",
marginTop: "0.8em",
marginBottom: "0.3em",
}}
>
{children}
</h5>
),
h6: ({ children }) => (
<h6
style={{
color: "var(--text-muted)",
marginTop: "0.8em",
marginBottom: "0.3em",
}}
>
{children}
</h6>
),
p: ({ children }) => (
<p
style={{
color: "var(--text-primary)",
lineHeight: 1.7,
marginBottom: "0.75em",
}}
>
{children}
</p>
),
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
style={{
color: "var(--accent-interactive)",
textDecoration: "underline",
}}
>
{children}
</a>
),
strong: ({ children }) => (
<strong style={{ color: "var(--text-primary)", fontWeight: 600 }}>
{children}
</strong>
),
em: ({ children }) => (
<em style={{ color: "var(--text-secondary)" }}>{children}</em>
),
blockquote: ({ children }) => (
<blockquote
style={{
borderLeft: "3px solid var(--border-strong)",
margin: "0.75em 0",
padding: "4px 16px",
color: "var(--text-secondary)",
background: "var(--bg-muted)",
borderRadius: "0 4px 4px 0",
}}
>
{children}
</blockquote>
),
hr: () => (
<hr
style={{
border: "none",
borderTop: "1px solid var(--border-default)",
margin: "1.2em 0",
}}
/>
),
ul: ({ children }) => (
<ul
style={{
color: "var(--text-primary)",
paddingLeft: "1.5em",
marginBottom: "0.75em",
}}
>
{children}
</ul>
),
ol: ({ children }) => (
<ol
style={{
color: "var(--text-primary)",
paddingLeft: "1.5em",
marginBottom: "0.75em",
}}
>
{children}
</ol>
),
li: ({ children }) => (
<li
style={{
color: "var(--text-primary)",
lineHeight: 1.6,
marginBottom: "0.2em",
}}
>
{children}
</li>
),
code: ({ children, className }) => {
const isBlock = className?.startsWith("language-");
if (isBlock) return <code className={className}>{children}</code>;
return (
<code
style={{
background: "var(--bg-muted)",
color: "var(--code-kw-color)",
border: "1px solid var(--border-subtle)",
borderRadius: 3,
padding: "1px 5px",
fontSize: "0.85em",
fontFamily: "monospace",
}}
>
{children}
</code>
);
},
pre: ({ children }) => {
const codeText = React.isValidElement(children)
? String((children.props as { children?: unknown }).children ?? "")
@@ -44,12 +217,15 @@ const components: Components = {
<div style={{ position: "relative", margin: "8px 0" }}>
<pre
style={{
background: "var(--mantine-color-gray-1)",
background: "var(--bg-muted)",
color: "var(--text-primary)",
border: "1px solid var(--border-default)",
padding: "10px 52px 10px 14px",
borderRadius: 4,
overflowX: "auto",
fontSize: "0.85em",
margin: 0,
fontFamily: "monospace",
}}
>
{children}
@@ -65,18 +241,33 @@ const components: Components = {
borderCollapse: "collapse",
width: "100%",
fontSize: "0.85em",
color: "var(--text-primary)",
}}
>
{children}
</table>
</div>
),
thead: ({ children }) => (
<thead style={{ background: "var(--bg-muted)" }}>{children}</thead>
),
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => (
<tr
style={{
borderBottom: "1px solid var(--border-default)",
}}
>
{children}
</tr>
),
th: ({ children, style }) => (
<th
style={{
border: "1px solid var(--mantine-color-gray-3)",
border: "1px solid var(--border-default)",
padding: "6px 10px",
background: "var(--mantine-color-gray-1)",
background: "var(--bg-raised)",
color: "var(--text-primary)",
textAlign: "left",
fontWeight: 600,
whiteSpace: "nowrap",
@@ -89,8 +280,9 @@ const components: Components = {
td: ({ children, style }) => (
<td
style={{
border: "1px solid var(--mantine-color-gray-3)",
border: "1px solid var(--border-default)",
padding: "5px 10px",
color: "var(--text-primary)",
...style,
}}
>
@@ -92,8 +92,10 @@ export function TextViewer({ file, isMarkdown }: TextViewerProps) {
maxWidth: 800,
margin: "0 auto",
padding: "20px 28px",
background: "#ffffff",
background: "var(--bg-surface)",
color: "var(--text-primary)",
borderRadius: 6,
border: "1px solid var(--border-subtle)",
}}
>
{renderedMarkdown}
+1 -1
View File
@@ -1,6 +1,6 @@
// Theme constants and utilities
export type ThemeMode = "light" | "dark" | "rainbow";
export type ThemeMode = "light" | "dark" | "midnight" | "rainbow";
// Detect OS theme preference
export function getSystemTheme(): "light" | "dark" {
@@ -1,8 +1,6 @@
import { PageEditorFunctions } from "@app/types/pageEditor";
import {
type ToolPanelMode,
DEFAULT_TOOL_PANEL_MODE,
} from "@app/constants/toolPanel";
import { type ToolPanelMode } from "@app/constants/toolPanel";
import { preferencesService } from "@app/services/preferencesService";
export interface ToolWorkflowState {
// UI State
@@ -43,7 +41,7 @@ export const baseState: Omit<ToolWorkflowState, "toolPanelMode"> = {
export const createInitialState = (): ToolWorkflowState => ({
...baseState,
toolPanelMode: DEFAULT_TOOL_PANEL_MODE,
toolPanelMode: preferencesService.getPreference("defaultToolPanelMode"),
});
export function toolWorkflowReducer(
@@ -13,6 +13,31 @@ interface UseToolPanelGeometryOptions {
quickAccessRef: RefObject<HTMLDivElement | null>;
}
function computeGeometry(
panelEl: HTMLDivElement,
quickAccessRef: RefObject<HTMLDivElement | null>,
): ToolPanelGeometry {
const rect = panelEl.getBoundingClientRect();
const isRTL =
typeof document !== "undefined" && document.documentElement.dir === "rtl";
let width: number;
let left: number;
if (isRTL) {
// RTL: panel is on the left, expands rightward
width = Math.max(360, window.innerWidth - rect.right);
left = rect.right;
} else {
// LTR: panel is on the right, expands leftward to the file sidebar
const quickAccessRect = quickAccessRef.current?.getBoundingClientRect();
const leftOffset = quickAccessRect ? quickAccessRect.right : 0;
width = Math.max(360, rect.right - leftOffset);
left = leftOffset;
}
const height = Math.max(rect.height, window.innerHeight - rect.top);
return { left, top: rect.top, width, height };
}
export function useToolPanelGeometry({
enabled,
toolPanelRef,
@@ -36,31 +61,7 @@ export function useToolPanelGeometry({
let rafId: number | null = null;
const computeAndSetGeometry = () => {
const rect = panelEl.getBoundingClientRect();
const isRTL =
typeof document !== "undefined" &&
document.documentElement.dir === "rtl";
let width: number;
let left: number;
if (isRTL) {
// RTL: panel is on the left, expands rightward
width = Math.max(360, window.innerWidth - rect.right);
left = rect.right;
} else {
// LTR: panel is on the right, expands leftward to the file sidebar
const quickAccessRect = quickAccessRef.current?.getBoundingClientRect();
const leftOffset = quickAccessRect ? quickAccessRect.right : 0;
width = Math.max(360, rect.right - leftOffset);
left = leftOffset;
}
const height = Math.max(rect.height, window.innerHeight - rect.top);
setGeometry({
left,
top: rect.top,
width,
height,
});
setGeometry(computeGeometry(panelEl, quickAccessRef));
};
const scheduleUpdate = () => {
@@ -175,9 +175,13 @@ export function useRainbowTheme(): RainbowThemeHook {
return;
}
// Normal theme switching
const nextTheme = themeMode === "light" ? "dark" : "light";
updatePreference("theme", nextTheme);
// Normal theme switching: light → dark → midnight → light
const cycle: Record<string, ThemeMode> = {
light: "dark",
dark: "midnight",
midnight: "light",
};
updatePreference("theme", cycle[themeMode] ?? "light");
}, [themeMode, updatePreference]);
const activateRainbow = useCallback(() => {
+3 -3
View File
@@ -18,7 +18,7 @@ import { useViewer } from "@app/contexts/ViewerContext";
import { useLocation } from "react-router-dom";
import AppsIcon from "@mui/icons-material/AppsRounded";
import ToolPanel from "@app/components/tools/ToolPanel";
import RightSidebar from "@app/components/tools/RightSidebar";
import Workbench from "@app/components/layout/Workbench";
import FileSidebar from "@app/components/shared/FileSidebar";
import FileManager from "@app/components/FileManager";
@@ -290,7 +290,7 @@ export default function HomePage() {
aria-label={t("home.mobile.toolsSlide", "Tool selection panel")}
>
<div className="mobile-slide-content">
<ToolPanel />
<RightSidebar />
</div>
</div>
<div
@@ -381,7 +381,7 @@ export default function HomePage() {
onOpenSettings={() => setConfigModalOpen(true)}
/>
<Workbench />
{!hideToolPanel && <ToolPanel />}
{!hideToolPanel && <RightSidebar />}
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
<AppConfigModal
opened={configModalOpen}
+328 -219
View File
@@ -14,12 +14,6 @@
--spdf-compare-inline-added-bg: rgba(52, 199, 89, 0.25);
/* File Manager active file colors */
--file-active-bg: rgba(
59,
130,
246,
0.1
); /* Transparent blue for active file background */
--file-active-badge-bg: rgba(
34,
197,
@@ -43,9 +37,7 @@
--fullscreen-anim-duration-out: 0.22s;
/* Z-index constants (added in this PR) */
--z-over-fullscreen-surface: 1400;
--z-fullscreen-surface: 1200;
--z-fullscreen-favorite-star: 2;
--z-fullscreen-icon-svg: 1;
--z-toolpicker-star: 1;
@@ -162,10 +154,8 @@
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
/* Font weights */
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Light theme semantic colors */
--bg-surface: #ffffff;
@@ -192,22 +182,9 @@
/* Icon colors for light mode */
--icon-user-bg: #9ca3af;
--icon-user-color: #ffffff;
--icon-notifications-bg: #9ca3af;
--icon-notifications-color: #ffffff;
--icon-tools-bg: #1e88e5;
--icon-tools-color: #ffffff;
--icon-read-bg: #4caf50;
--icon-read-color: #ffffff;
--icon-sign-bg: #3ba99c;
--icon-sign-color: #ffffff;
--icon-automate-bg: #a576e3;
--icon-automate-color: #ffffff;
--icon-files-bg: #d3e7f7;
--icon-files-color: #0a8bff;
--icon-activity-bg: #d3e7f7;
--icon-activity-color: #0a8bff;
--icon-config-bg: #9ca3af;
--icon-config-color: #ffffff;
/* Colors for tooltips */
--tooltip-title-bg: #dbefff;
@@ -224,7 +201,6 @@
--tools-text-and-icon-color: var(--text-primary);
/* Tool picker sticky header variables (light mode) */
--tool-header-bg: #dbefff;
--tool-header-border: #bee2ff;
--tool-header-text: #1e88e5;
--tool-header-badge-bg: #c0ddff;
@@ -235,7 +211,6 @@
--tool-subcategory-rule-color: #e5e7eb; /* doubly lighter rule line */
--accent-interactive: #4a90e2;
--text-instruction: #4a90e2;
--text-brand: var(--color-gray-700);
--text-brand-accent: #dc2626;
/* PDF text selection colors */
@@ -251,16 +226,13 @@
--tool-panel-search-border-bottom: #eff1f4;
/* container */
--landing-paper-bg: var(--bg-surface);
--landing-inner-paper-bg: #eef8ff;
--landing-inner-paper-border: #cdeaff;
--landing-button-bg: var(--bg-surface);
--landing-button-color: var(--icon-tools-bg);
--landing-button-border: #e0f2f7;
--landing-button-hover-bg: rgb(251, 251, 251);
/* drop state */
--landing-drop-paper-bg: #e3f2fd;
--landing-drop-inner-paper-bg: #bbdefb;
--landing-drop-inner-paper-border: #90caf9;
@@ -268,36 +240,15 @@
--landing-hero-gradient: linear-gradient(135deg, #4c8bf5 0%, #3a7be8 100%);
--landing-stack-w: 224px;
--landing-stack-h: 176px;
--landing-stack-glow-bg: radial-gradient(
circle,
rgba(74, 144, 226, 0.18) 0%,
transparent 70%
);
/* landing doc stack shadows */
--landing-doc-shadow-back-idle:
0 4px 20px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04);
--landing-doc-shadow-back-hover:
0 12px 40px rgba(0, 0, 0, 0.15), 0 4px 12px rgba(0, 0, 0, 0.08);
--landing-doc-shadow-front-idle:
0 8px 30px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.06),
0 0 0 1px rgba(0, 0, 0, 0.02);
--landing-doc-shadow-front-hover:
0 18px 48px rgba(0, 0, 0, 0.18), 0 8px 20px rgba(0, 0, 0, 0.1),
0 0 0 1px rgba(0, 0, 0, 0.04);
/* landing action button shadows */
--landing-action-transition:
transform 0.28s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.32s cubic-bezier(0.4, 0, 0.2, 1);
--landing-action-shadow-idle:
0 6px 18px rgba(0, 0, 0, 0), 0 2px 6px rgba(0, 0, 0, 0);
--landing-action-shadow-hover:
0 6px 18px rgba(0, 0, 0, 0.14), 0 2px 6px rgba(0, 0, 0, 0.08);
--landing-action-primary-shadow-idle:
0 10px 26px rgba(58, 123, 232, 0), 0 4px 12px rgba(0, 0, 0, 0);
--landing-action-primary-shadow-hover:
0 10px 26px rgba(58, 123, 232, 0.42), 0 4px 12px rgba(0, 0, 0, 0.1);
/* selected file header colors */
--header-selected-bg: #1e88e5; /* light mode selected header matches dark */
@@ -306,18 +257,10 @@
--accordion-item-bg: #e8eaed; /* accordion item background - more distinguishable */
/* shadows */
--drop-shadow-color: rgba(0, 0, 0, 0.08);
--drop-shadow-color-strong: rgba(0, 0, 0, 0.04);
--drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(0, 0, 0, 0.08))
drop-shadow(0 0.6rem 0.6rem rgba(0, 0, 0, 0.06))
drop-shadow(0 1.2rem 1rem rgba(0, 0, 0, 0.04));
/* Light mode card hover and selection */
--header-hover-bg: #3b4b6e; /* same family as selected, a touch muted for hover */
--card-selected-border: #3fafff; /* slightly more blue than dark mode header */
--checkbox-border: #2f83bf;
--checkbox-checked-bg: #3fafff;
--checkbox-tick: #ffffff;
--information-text-bg: #eaeaea;
--information-text-color: #5e5e5e;
@@ -348,15 +291,9 @@
--api-keys-button-color: #333333;
/* PDF Report Colors (always light) */
--pdf-light-header-bg: 239 246 255;
--pdf-light-accent: 59 130 246;
--pdf-light-text-primary: 30 41 59;
--pdf-light-text-muted: 100 116 139;
--pdf-light-box-bg: 248 250 252;
--pdf-light-box-border: 226 232 240;
--pdf-light-warning: 234 179 8;
--pdf-light-danger: 248 113 113;
--pdf-light-success: 34 197 94;
--pdf-light-neutral: 148 163 184;
--pdf-light-status-valid-bg: 209 250 229;
--pdf-light-status-valid-text: 6 95 70;
@@ -366,8 +303,6 @@
--pdf-light-status-invalid-text: 153 27 27;
--pdf-light-status-neutral-bg: 229 231 235;
--pdf-light-status-neutral-text: 55 65 81;
--pdf-light-report-container-bg: 249 250 251;
--pdf-light-simulated-page-bg: 255 255 255;
--pdf-light-simulated-page-text: 15 23 42;
/* Code token colors (light mode) */
@@ -376,11 +311,6 @@
--code-num-color: #4338ca; /* indigo-700 */
--code-com-color: #6b7280; /* gray-500 */
/* Compare tool specific colors - only for colors that don't have existing theme pairs */
--compare-upload-dropzone-bg: rgba(241, 245, 249, 0.45);
--compare-upload-dropzone-border: rgba(148, 163, 184, 0.6);
--compare-upload-icon-bg: rgba(148, 163, 184, 0.2);
--compare-upload-icon-color: rgba(17, 24, 39, 0.75);
--compare-upload-divider: rgba(148, 163, 184, 0.5);
/* Compare page label chip (light mode): slightly lighter than surrounding rows */
--compare-page-label-bg: var(--bg-muted);
@@ -420,12 +350,12 @@
}
[data-mantine-color-scheme="dark"] {
/* Dark theme gray scale (inverted) */
--gray-50: 17 24 39;
--gray-100: 31 35 41;
--gray-200: 42 47 54;
--gray-300: 55 65 81;
--gray-400: 75 85 99;
/* Dark theme gray scale — very dark, near-black */
--gray-50: 7 10 14;
--gray-100: 10 13 18;
--gray-200: 15 19 24;
--gray-300: 22 28 36;
--gray-400: 36 44 58;
--gray-500: 107 114 128;
--gray-600: 156 163 175;
--gray-700: 209 213 219;
@@ -433,9 +363,9 @@
--gray-900: 243 244 246;
/* Dark semantic colors for Tailwind */
--surface: 31 35 41;
--background: 42 47 54;
--border: 55 65 81;
--surface: 15 19 24;
--background: 10 13 18;
--border: 22 28 36;
/* Dark theme Mantine colors */
--color-red-50: #2d1b1b;
@@ -449,11 +379,11 @@
--color-red-800: #991b1b;
--color-red-900: #7f1d1d;
--color-gray-50: #111827;
--color-gray-100: #1f2329;
--color-gray-200: #2a2f36;
--color-gray-300: #374151;
--color-gray-400: #4b5563;
--color-gray-50: #070a0e;
--color-gray-100: #0a0d12;
--color-gray-200: #0f1318;
--color-gray-300: #161c24;
--color-gray-400: #242c3a;
--color-gray-500: #6b7280;
--color-gray-600: #9ca3af;
--color-gray-700: #d1d5db;
@@ -461,20 +391,20 @@
--color-gray-900: #f3f4f6;
/* Category colors - same as light mode for consistency */
--category-color-removal: #ef4444; /* Red for removal tools */
--category-color-security: #f59e0b; /* Orange for security tools */
--category-color-formatting: #8b5cf6; /* Purple for formatting tools */
--category-color-extraction: #06b6d4; /* Cyan for extraction tools */
--category-color-signing: #10b981; /* Green for signing tools */
--category-color-general: #3b82f6; /* Blue for general tools */
--category-color-verification: #f97316; /* Orange for verification tools */
--category-color-automation: #ec4899; /* Pink for automation tools */
--category-color-developer: #6b7280; /* Gray for developer tools */
--category-color-default: #6b7280; /* Default gray */
--category-color-removal: #ef4444;
--category-color-security: #f59e0b;
--category-color-formatting: #8b5cf6;
--category-color-extraction: #06b6d4;
--category-color-signing: #10b981;
--category-color-general: #3b82f6;
--category-color-verification: #f97316;
--category-color-automation: #ec4899;
--category-color-developer: #6b7280;
--category-color-default: #6b7280;
/* Special section colors - same as light mode for consistency */
--special-color-favorites: #ffc107; /* Yellow/gold for favorites */
--special-color-recommended: #1bb1d4; /* Cyan for recommended */
--special-color-favorites: #ffc107;
--special-color-recommended: #1bb1d4;
/* Success (green) - dark */
--color-green-50: #052e16;
@@ -501,53 +431,39 @@
--color-yellow-900: #fef9c3;
/* Dark theme semantic colors */
--bg-surface: #2a2f36;
--bg-raised: #1f2329;
--bg-muted: #1f2329;
--bg-background: #2a2f36;
--bg-toolbar: #1f2329;
--bg-file-manager: #1f2329;
--bg-file-list: #2a2f36;
--bg-surface: #0f1318;
--bg-raised: #0a0d12;
--bg-muted: #0a0d12;
--bg-background: #0a0d12;
--bg-toolbar: #0a0d12;
--bg-file-manager: #0a0d12;
--bg-file-list: #0f1318;
--btn-open-file: #0a8bff;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-muted: #9ca3af;
/* Always-dark text (for use on light backgrounds like alerts) - does not change in dark mode */
--text-primary: #f0f2f5;
--text-secondary: #c5ccd6;
--text-muted: #8b96a3;
--text-always-dark: #1f2937;
--text-always-dark-muted: #6b7280;
--border-subtle: #2a2f36;
--border-default: #3a4047;
--border-strong: #4b5563;
--hover-bg: #374151;
--active-bg: #4b5563;
--border-subtle: #0f1318;
--border-default: #161c24;
--border-strong: #242c3a;
--hover-bg: #13181f;
--active-bg: #1a2030;
--automation-entry-hover-bg: var(--color-gray-200);
/* Icon colors for dark mode */
--icon-user-bg: #2a2f36;
--icon-user-color: #6e7581;
--icon-notifications-bg: #2a2f36;
--icon-notifications-color: #6e7581;
--icon-tools-bg: #4b525a;
--icon-tools-color: #eaeaea;
--icon-read-bg: #4b525a;
--icon-read-color: #eaeaea;
--icon-sign-bg: #4b525a;
--icon-sign-color: #eaeaea;
--icon-automate-bg: #4b525a;
--icon-automate-color: #eaeaea;
--icon-files-bg: #4b525a;
--icon-files-color: #eaeaea;
--icon-activity-bg: #4b525a;
--icon-activity-color: #eaeaea;
--icon-config-bg: #4b525a;
--icon-config-color: #eaeaea;
--icon-user-bg: #0f1318;
--icon-user-color: #5a6475;
--icon-tools-bg: #161c24;
--icon-tools-color: #d8dce4;
--icon-files-color: #d8dce4;
/* Inactive icon colors for dark mode */
--icon-inactive-bg: #2a2f36;
--icon-inactive-color: #6e7581;
--icon-inactive-bg: #0f1318;
--icon-inactive-color: #5a6475;
/* Dark mode tooltip colors */
--tooltip-title-bg: #4b525a;
--tooltip-title-bg: #161c24;
--tooltip-title-color: #fff;
--tooltip-header-bg: var(--bg-raised);
--tooltip-header-color: var(--text-primary);
@@ -555,7 +471,6 @@
--accent-interactive: #ffffff;
--text-instruction: #ffffff;
--text-brand: var(--color-gray-800);
--text-brand-accent: #ef4444;
/* Compare badge text colors (dark mode): lighter for readability */
@@ -563,62 +478,41 @@
--spdf-compare-added-badge-fg: var(--color-green-500);
/* container */
--landing-paper-bg: #171a1f;
--landing-inner-paper-bg: var(--bg-raised);
--landing-inner-paper-border: #2d3237;
--landing-inner-paper-border: #161c24;
/* landing dark overrides */
--landing-stack-glow-bg: radial-gradient(
circle,
rgba(30, 136, 229, 0.22) 0%,
transparent 70%
);
--landing-doc-shadow-back-idle:
0 4px 20px rgba(0, 0, 0, 0.35), 0 1px 3px rgba(0, 0, 0, 0.25);
--landing-doc-shadow-back-hover:
0 14px 44px rgba(0, 0, 0, 0.55), 0 6px 16px rgba(0, 0, 0, 0.35);
0 4px 20px rgba(0, 0, 0, 0.6), 0 1px 3px rgba(0, 0, 0, 0.4);
--landing-doc-shadow-front-idle:
0 8px 30px rgba(0, 0, 0, 0.45), 0 4px 12px rgba(0, 0, 0, 0.3),
0 8px 30px rgba(0, 0, 0, 0.7), 0 4px 12px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.04);
--landing-doc-shadow-front-hover:
0 20px 52px rgba(0, 0, 0, 0.6), 0 10px 24px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 255, 255, 0.06);
--landing-button-color: #ffffff;
--landing-button-hover-bg: var(--bg-raised);
/* selected file header colors for dark */
--header-selected-bg: #1e88e5;
--header-selected-fg: #ffffff;
/* file card background (dark) */
--file-card-bg: #1f2329;
--accordion-item-bg: #373d45; /* accordion item background - more distinguishable */
/* shadows */
--drop-shadow-color: rgba(255, 255, 255, 0.08);
--drop-shadow-color-strong: rgba(255, 255, 255, 0.04);
--drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(200, 200, 200, 0.08))
drop-shadow(0 0.6rem 0.6rem rgba(200, 200, 200, 0.06))
drop-shadow(0 1.2rem 1rem rgba(200, 200, 200, 0.04));
--file-card-bg: #0a0d12;
--accordion-item-bg: #13181f;
/* Adjust shadows for dark mode */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.4);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4);
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.5);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.6);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.6);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.6);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.6);
--tools-text-and-icon-color: var(--text-primary);
/* Tool picker sticky header variables (dark mode) */
--tool-header-bg: #2a2f36;
--tool-header-border: #3a4047;
--tool-header-text: #d0d6dc;
--tool-header-badge-bg: #4b525a;
--tool-header-badge-text: #ffffff;
--tool-header-border: #161c24;
--tool-header-text: #c5ccd6;
--tool-header-badge-bg: #161c24;
--tool-header-badge-text: #f0f2f5;
/* Subcategory title styling (dark mode) */
--tool-subcategory-text-color: #9ca3af; /* lighter text in dark mode as well */
--tool-subcategory-rule-color: #3a4047; /* doubly lighter (relative) line in dark */
--tool-subcategory-text-color: #8b96a3;
--tool-subcategory-rule-color: #161c24;
/* PDF text selection colors (dark mode) */
--pdf-selection-bg: rgba(96, 165, 250, 0.25);
@@ -626,74 +520,67 @@
/* Placeholder text colors (dark mode) */
--search-text-and-icon-color: #ffffff !important;
--input-bg: #4b525a;
--input-bg: #161c24;
/* Tool panel search bar background colors (dark mode) */
--tool-panel-search-bg: #1f2329;
--tool-panel-search-border-bottom: #4b525a;
--tool-panel-search-bg: #0a0d12;
--tool-panel-search-border-bottom: #242c3a;
--information-text-bg: #292e34;
--information-text-color: #ececec;
--information-text-bg: #0a0d12;
--information-text-color: #d8dce4;
/* Bulk selection panel specific colors (dark mode) */
--bulk-panel-bg: var(--bg-raised); /* dark background for parent container */
--bulk-card-bg: var(--bg-raised); /* dark background for cards */
--bulk-card-border: var(
--border-default
); /* default border for cards and buttons */
--bulk-card-hover-border: var(--border-strong); /* stronger border on hover */
--unsupported-bar-bg: #1f2329;
--unsupported-bar-border: #4b525a;
--bulk-panel-bg: var(--bg-raised);
--bulk-card-bg: var(--bg-raised);
--bulk-card-border: var(--border-default);
--bulk-card-hover-border: var(--border-strong);
--unsupported-bar-bg: #0a0d12;
--unsupported-bar-border: #242c3a;
/* Config Modal colors (dark mode) */
--modal-nav-bg: #1f2329;
--modal-nav-section-title: #9ca3af;
--modal-nav-item: #d0d6dc;
--modal-nav-bg: #0a0d12;
--modal-nav-section-title: #8b96a3;
--modal-nav-item: #c5ccd6;
--modal-nav-item-active: #0a8bff;
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.15);
--modal-content-bg: #2a2f36;
--modal-content-bg: #0f1318;
--modal-header-border: rgba(255, 255, 255, 0.08);
/* Onboarding (dark mode) */
--onboarding-title: #f9fafb;
--onboarding-body: #d1d5db;
--onboarding-title: #f0f2f5;
--onboarding-body: #c5ccd6;
--onboarding-primary-button-bg: #ffffff;
--onboarding-primary-button-text: #0b1220;
--onboarding-primary-button-text: #0a0d12;
--onboarding-secondary-button-bg: transparent;
--onboarding-secondary-button-text: #e5e7eb;
--onboarding-secondary-button-border: #3a4047;
--onboarding-step-active: #e5e7eb;
--onboarding-step-inactive: #4b5563;
--onboarding-secondary-button-text: #d8dce4;
--onboarding-secondary-button-border: #161c24;
--onboarding-step-active: #d8dce4;
--onboarding-step-inactive: #242c3a;
/* API Keys section colors (dark mode) */
--api-keys-card-bg: #2a2f36;
--api-keys-card-border: #3a4047;
--api-keys-card-bg: #0f1318;
--api-keys-card-border: #161c24;
--api-keys-card-shadow: none;
--api-keys-input-bg: #1f2329;
--api-keys-input-border: #3a4047;
--api-keys-button-bg: #3a4047;
--api-keys-button-color: #d0d6dc;
--api-keys-input-bg: #0a0d12;
--api-keys-input-border: #161c24;
--api-keys-button-bg: #161c24;
--api-keys-button-color: #c5ccd6;
/* Code token colors (dark mode - Cursor-like) */
--code-kw-color: #c792ea; /* purple */
--code-str-color: #c3e88d; /* green */
--code-num-color: #f78c6c; /* orange */
--code-com-color: #697098; /* muted gray-blue */
/* Compare tool specific colors (dark mode) - only for colors that don't have existing theme pairs */
--compare-upload-dropzone-bg: rgba(31, 35, 41, 0.45);
--compare-upload-dropzone-border: rgba(75, 85, 99, 0.6);
--compare-upload-icon-bg: rgba(75, 85, 99, 0.2);
--compare-upload-icon-color: rgba(243, 244, 246, 0.75);
--compare-upload-divider: rgba(75, 85, 99, 0.5);
--code-kw-color: #c792ea;
--code-str-color: #c3e88d;
--code-num-color: #f78c6c;
--code-com-color: #697098;
/* Compare page label chip (dark mode): slightly darker than surrounding rows */
--compare-page-label-bg: #1f2329;
/* Compare page label chip (dark mode) */
--compare-page-label-bg: #0a0d12;
--compare-page-label-fg: var(--text-secondary);
/* Status indicator dot ring shadow (dark) */
--status-dot-ring: 0 0 0 2px rgba(255, 255, 255, 0.18);
/* Input element styling (dark) */
--input-bg: #4b525a;
--input-bg: #161c24;
/* Select/dropdown placeholder state (dark) */
--select-placeholder-bg: var(--mantine-color-dark-5);
@@ -709,6 +596,195 @@
--dropdown-group-label: var(--mantine-color-dark-2);
}
/* ── Midnight theme — blue-tinted dark workbench ── */
/* Applied on top of dark mode: html has both data-mantine-color-scheme="dark"
and data-color-scheme="midnight" */
[data-color-scheme="midnight"] {
--gray-50: 21 26 38;
--gray-100: 30 36 52;
--gray-200: 45 50 72;
--gray-300: 58 65 90;
--gray-400: 75 85 99;
--gray-500: 107 114 128;
--gray-600: 156 163 175;
--gray-700: 209 213 219;
--gray-800: 229 231 235;
--gray-900: 243 244 246;
--surface: 30 36 52;
--background: 21 26 38;
--border: 58 65 90;
--color-gray-50: #151a24;
--color-gray-100: #1a2030;
--color-gray-200: #1e2435;
--color-gray-300: #252836;
--color-gray-400: #2d3340;
--color-gray-500: #6b7280;
--color-gray-600: #9ca3af;
--color-gray-700: #d1d5db;
--color-gray-800: #e5e7eb;
--color-gray-900: #f3f4f6;
--category-color-general: #4b77e6;
--bg-surface: #1e2435;
--bg-raised: #1a2030;
--bg-muted: #1a2030;
--bg-background: #151a24;
--bg-toolbar: #1e2435;
--bg-file-manager: #1a2030;
--bg-file-list: #1e2435;
--btn-open-file: #4b77e6;
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--text-muted: #94a3b8;
--text-always-dark: #1f2937;
--text-always-dark-muted: #6b7280;
--border-subtle: #2d3340;
--border-default: #3a4152;
--border-strong: #4a5568;
--hover-bg: #252836;
--active-bg: #2d3340;
--automation-entry-hover-bg: var(--color-gray-200);
--icon-user-bg: #1e2435;
--icon-user-color: #6b7280;
--icon-tools-bg: #2d3340;
--icon-tools-color: #cbd5e1;
--icon-files-color: #4b77e6;
--icon-inactive-bg: #1e2435;
--icon-inactive-color: #6b7280;
--tooltip-title-bg: #252836;
--tooltip-title-color: #f1f5f9;
--tooltip-header-bg: #1a2030;
--tooltip-header-color: #f1f5f9;
--tooltip-border: var(--border-default);
--accent-interactive: #4b77e6;
--text-instruction: #4b77e6;
--text-brand-accent: #60a5fa;
--spdf-compare-removed-badge-fg: var(--color-red-500);
--spdf-compare-added-badge-fg: var(--color-green-500);
--landing-inner-paper-bg: #1a2030;
--landing-inner-paper-border: #3a4152;
--landing-button-bg: #252836;
--landing-button-color: #f1f5f9;
--landing-button-border: #3a4152;
--landing-drop-inner-paper-bg: #1e2435;
--landing-drop-inner-paper-border: #3a4152;
--landing-doc-shadow-back-idle:
0 4px 20px rgba(0, 0, 0, 0.45), 0 1px 3px rgba(0, 0, 0, 0.3);
--landing-doc-shadow-front-idle:
0 8px 30px rgba(0, 0, 0, 0.55), 0 4px 12px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(75, 119, 230, 0.08);
--header-selected-bg: #4b77e6;
--header-selected-fg: #ffffff;
--file-card-bg: #1a2030;
--accordion-item-bg: #252836;
--card-selected-border: #4b77e6;
--checkbox-checked-bg: #4b77e6;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.5);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.5);
--tools-text-and-icon-color: #cbd5e1;
--tool-header-border: #3a4152;
--tool-header-text: #cbd5e1;
--tool-header-badge-bg: #252836;
--tool-header-badge-text: #f1f5f9;
--tool-subcategory-text-color: #94a3b8;
--tool-subcategory-rule-color: #3a4152;
--pdf-selection-bg: rgba(75, 119, 230, 0.25);
--pdf-selection-ring: rgba(75, 119, 230, 0.28);
--search-text-and-icon-color: #f1f5f9 !important;
--input-bg: #252836;
--tool-panel-search-bg: #1a2030;
--tool-panel-search-border-bottom: #3a4152;
--information-text-bg: #1e2435;
--information-text-color: #cbd5e1;
--bulk-panel-bg: var(--bg-raised);
--bulk-card-bg: var(--bg-raised);
--bulk-card-border: var(--border-default);
--bulk-card-hover-border: var(--border-strong);
--unsupported-bar-bg: #1a2030;
--unsupported-bar-border: #3a4152;
--modal-nav-bg: #1a2030;
--modal-nav-section-title: #94a3b8;
--modal-nav-item: #cbd5e1;
--modal-nav-item-active: #4b77e6;
--modal-nav-item-active-bg: rgba(75, 119, 230, 0.18);
--modal-content-bg: #1e2435;
--modal-header-border: rgba(255, 255, 255, 0.08);
--onboarding-title: #f1f5f9;
--onboarding-body: #cbd5e1;
--onboarding-primary-button-bg: #4b77e6;
--onboarding-primary-button-text: #ffffff;
--onboarding-secondary-button-bg: transparent;
--onboarding-secondary-button-text: #cbd5e1;
--onboarding-secondary-button-border: #3a4152;
--onboarding-step-active: #cbd5e1;
--onboarding-step-inactive: #3a4152;
--api-keys-card-bg: #1e2435;
--api-keys-card-border: #3a4152;
--api-keys-card-shadow: none;
--api-keys-input-bg: #1a2030;
--api-keys-input-border: #3a4152;
--api-keys-button-bg: #252836;
--api-keys-button-color: #cbd5e1;
--code-kw-color: #7aa2f7;
--code-str-color: #9ece6a;
--code-num-color: #ff9e64;
--code-com-color: #565f89;
--compare-page-label-bg: #1a2030;
--compare-page-label-fg: var(--text-secondary);
--status-dot-ring: 0 0 0 2px rgba(75, 119, 230, 0.25);
--select-placeholder-bg: var(--mantine-color-dark-5);
--select-placeholder-text: var(--mantine-color-dark-2);
--dropdown-trigger-bg: var(--mantine-color-dark-6);
--dropdown-trigger-text: var(--mantine-color-dark-0);
--dropdown-trigger-text-disabled: var(--mantine-color-dark-1);
--dropdown-trigger-icon: var(--mantine-color-dark-2);
--dropdown-panel-bg: var(--mantine-color-dark-7);
--dropdown-panel-border: var(--mantine-color-dark-4);
--dropdown-group-label: var(--mantine-color-dark-2);
--link-hover-bg: rgba(75, 119, 230, 0.06);
--link-hover-border: rgba(75, 119, 230, 0.2);
--link-hover-shadow:
0 0 0 0.5px rgba(75, 119, 230, 0.1), 0 1px 3px rgba(75, 119, 230, 0.08);
--link-focus-ring: rgba(75, 119, 230, 0.35);
--link-toolbar-bg: rgba(21, 26, 36, 0.94);
--link-toolbar-border: rgba(255, 255, 255, 0.1);
--link-toolbar-shadow:
0 12px 40px rgba(0, 0, 0, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3),
0 0 0 0.5px rgba(75, 119, 230, 0.08);
}
/* Dropzone drop state styling */
[data-accept] .dropzone-inner {
background-color: var(--landing-drop-inner-paper-bg) !important;
@@ -717,7 +793,6 @@
/* Plan section card borders - only override in dark mode */
[data-mantine-color-scheme="dark"] .plan-card {
--paper-border-color: rgb(158, 158, 158) !important;
}
[data-mantine-color-scheme="dark"] .plan-card [data-size="sm"] {
@@ -739,6 +814,19 @@
background-color: #1c598e !important;
}
[data-color-scheme="midnight"]
.plan-button:not(:disabled):not([data-disabled]) {
background-color: #4b77e6 !important;
}
/* Override Mantine's internal body/surface colors for midnight */
[data-color-scheme="midnight"] {
--mantine-color-body: #151a24;
--mantine-color-dark-6: #1e2435;
--mantine-color-dark-7: #151a24;
--mantine-color-dark-9: #0f1318;
}
/* Lighter grey for disabled plan buttons */
.plan-button:disabled,
.plan-button[data-disabled] {
@@ -787,12 +875,14 @@
border-radius: 8px;
}
/* Smooth transitions for theme switching */
/* Smooth transitions for theme switching - backgrounds and borders only.
Color (text) is intentionally excluded: Mantine applies the color scheme
after the first paint, so a color transition would cause text to visibly
crawl from the wrong colour on every page load. */
* {
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
border-color 0.2s ease;
}
/* ── PDF Link Overlay (viewer) ── */
:root {
@@ -992,10 +1082,15 @@
--shadow-color: rgba(15, 23, 42, 0.55);
}
[data-theme="dark"] {
[data-theme="dark"],
[data-color-scheme="dark"] {
--shadow-color: rgba(0, 0, 0, 0.75);
}
[data-color-scheme="midnight"] {
--shadow-color: rgba(0, 0, 0, 0.85);
}
/* ---- Wordmark light/dark switching (use instead of JS colorScheme checks) ---- */
.wordmark-dark-only {
display: none;
@@ -1099,3 +1194,17 @@
animation-duration: 160ms;
animation-timing-function: cubic-bezier(0.2, 0, 0.2, 1);
}
/* Mantine animates a button's background-color and its text/icon colour on
* separate transitions, so when a filled button flips state (selected ↔
* unselected, enabled ↔ disabled) you briefly see e.g. a blue background
* with the old dark-grey label still showing through. Drop the colour
* tween — the swap is instant, but transforms/shadows/borders/opacity keep
* animating for hover and press feedback. */
.mantine-Button-root,
.mantine-ActionIcon-root,
.mantine-SegmentedControl-control,
.mantine-SegmentedControl-label,
.mantine-SegmentedControl-indicator {
transition-property: transform, box-shadow, border-color, opacity !important;
}
@@ -31,9 +31,9 @@ test.describe("Navigation", () => {
await page.locator('a[href="/merge"]').first().click();
await expect(page).toHaveURL(/\/merge/);
// In the redesigned UI, "Back to tools" button replaces the old "Tools" link
// In the redesigned UI, "Back to all tools" button replaces the old "Tools" link
await page
.getByRole("button", { name: /Back to tools/i })
.getByRole("button", { name: /Back to all tools/i })
.first()
.click();
await expect(page).toHaveURL("/");
@@ -36,10 +36,10 @@ test.describe("4. PDF Tool Pages - Common Patterns", () => {
await page.goto("/compress");
await page.waitForLoadState("domcontentloaded");
// Step 2: Click the "Back to tools" button in ToolPanel to go back to /.
// Step 2: Click the "Back to all tools" button in ToolPanel to go back to /.
// In the redesigned UI this replaces the old "Tools" sidebar link.
const homeLink = page
.getByRole("button", { name: /Back to tools/i })
.getByRole("button", { name: /Back to all tools/i })
.first();
await homeLink.click();
@@ -53,6 +53,19 @@ const gray: MantineColorsTuple = [
"var(--color-gray-900)",
];
const dark: MantineColorsTuple = [
"#c4c9d2",
"#9aa3b0",
"#9ca3af",
"#6b7280",
"#242c3a",
"#161c24",
"#0f1318",
"#0a0d12",
"#070a0e",
"#040507",
];
export const mantineTheme = createTheme({
// Primary color
primaryColor: "primary",
@@ -63,6 +76,7 @@ export const mantineTheme = createTheme({
green,
yellow,
gray,
dark,
},
// Spacing system - uses CSS variables
@@ -58,6 +58,7 @@ export interface AppConfig {
timestampDefaultTsaUrl?: string;
timestampCustomTsaUrls?: string[];
timestampTsaPresets?: { label: string; url: string }[];
aiEngineEnabled?: boolean;
}
export type AppConfigBootstrapMode = "blocking" | "non-blocking";
@@ -0,0 +1,26 @@
import { flushSync } from "react-dom";
type ViewTransitionDoc = Document & {
startViewTransition?: (cb: () => void) => { finished: Promise<void> };
};
/**
* Run a state update inside a View Transition so the browser cross-fades
* (and morphs any elements sharing a {@code view-transition-name}) between
* the before/after DOMs.
*
* Falls back to a plain synchronous update when the API is unavailable
* (Firefox <130, JSDOM, motion-reduced preference).
*/
export function withViewTransition(update: () => void): Promise<void> {
if (typeof document === "undefined") {
update();
return Promise.resolve();
}
const doc = document as ViewTransitionDoc;
if (doc.startViewTransition) {
return doc.startViewTransition(() => flushSync(update)).finished;
}
update();
return Promise.resolve();
}
@@ -8,6 +8,7 @@ import { CheckoutProvider } from "@app/contexts/CheckoutContext";
import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerInitializer";
import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext";
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
import { ChatProvider } from "@app/components/chat/ChatContext";
export function AppProviders({
children,
@@ -25,7 +26,7 @@ export function AppProviders({
<ServerExperienceProvider>
<CheckoutProvider>
<UpgradeBannerInitializer />
{children}
<ChatProvider>{children}</ChatProvider>
</CheckoutProvider>
</ServerExperienceProvider>
</UpdateSeatsProvider>
@@ -0,0 +1,363 @@
/* ─── Chat takeover ─────────────────────────────────────────────────────── */
.agents-takeover {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
background: var(--bg-toolbar);
z-index: 5;
view-transition-name: agents-rail;
}
/* ─── Sidebar agents section ─────────────────────────────────────────────── */
.agents-section {
padding: 0.5rem 0.75rem 0.875rem;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-toolbar);
flex-shrink: 0;
view-transition-name: agents-rail;
}
/* Hero button — real Stirling agent */
.agent-button {
display: block;
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-subtle);
border-radius: 0.625rem;
background: var(--mantine-color-body);
transition:
background 150ms ease-out,
border-color 150ms ease-out;
}
.agent-button:hover {
border-color: color-mix(
in srgb,
var(--mantine-color-gray-4) 60%,
var(--border-subtle)
);
background: var(--mantine-color-default-hover);
}
.agent-button__logo {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--mantine-color-blue-filled);
flex-shrink: 0;
}
/* Coming-soon agent rows */
.agents-sidebar-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
margin-top: 0.375rem;
}
.agent-button--coming-soon {
display: block;
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-subtle);
border-radius: 0.625rem;
background: var(--mantine-color-body);
cursor: not-allowed !important;
opacity: 0.65;
transition:
opacity 180ms ease-out,
transform 180ms ease-out,
border-color 120ms ease-out;
}
@starting-style {
.agent-button--coming-soon {
opacity: 0;
transform: translateY(5px);
}
}
.agent-button--coming-soon:hover {
opacity: 0.85;
border-color: color-mix(
in srgb,
var(--mantine-color-gray-4) 60%,
var(--border-subtle)
);
background: var(--mantine-color-default-hover);
}
.agent-button__icon-plain {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
color: var(--mantine-color-dimmed);
flex-shrink: 0;
}
.agents-view-all {
all: unset;
display: block;
width: 100%;
margin-top: 0.375rem;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--mantine-color-dimmed);
text-align: center;
cursor: pointer;
opacity: 0.7;
border-radius: 0.375rem;
transition: opacity 120ms ease-out;
box-sizing: border-box;
}
.agents-view-all:hover {
opacity: 1;
}
/* Dark mode — sidebar */
[data-mantine-color-scheme="dark"] .agent-button--coming-soon {
background: transparent;
border-color: color-mix(
in srgb,
var(--mantine-color-dark-4) 60%,
transparent
);
}
[data-mantine-color-scheme="dark"] .agent-button--coming-soon:hover {
border-color: var(--mantine-color-dark-3);
background: var(--mantine-color-dark-6);
}
[data-mantine-color-scheme="dark"] .agent-button {
background: transparent;
border-color: color-mix(
in srgb,
var(--mantine-color-dark-4) 60%,
transparent
);
}
[data-mantine-color-scheme="dark"] .agent-button:hover {
border-color: var(--mantine-color-dark-3);
background: var(--mantine-color-dark-6);
}
[data-mantine-color-scheme="dark"] .agent-button__logo {
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
}
/* ─── Collapsed-rail agent button ────────────────────────────────────────── */
.agents-collapsed-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: none;
border-radius: 0.5rem;
background: transparent;
color: var(--mantine-color-blue-filled);
cursor: pointer;
transition: background 120ms ease-out;
}
.agents-collapsed-btn:hover {
background: var(--mantine-color-default-hover);
}
/* ─── Fullscreen hero card ───────────────────────────────────────────────── */
/*
* .tool-panel__fullscreen-group--agents gives the gradient border via the
* padding-box / border-box trick. We only override the padding-box with a
* very light tint so the card doesn't read as a colourful surface.
*/
.agents-hero {
padding: 1.25rem 1.5rem 1.5rem;
border-radius: 1rem;
background:
linear-gradient(
125deg,
color-mix(in srgb, var(--mantine-color-blue-1) 35%, white) 0%,
color-mix(in srgb, var(--mantine-color-violet-1) 45%, white) 50%,
color-mix(in srgb, var(--mantine-color-pink-0) 35%, white) 100%
)
padding-box,
linear-gradient(
135deg,
var(--mantine-color-blue-6) 0%,
var(--mantine-color-violet-6) 45%,
var(--mantine-color-pink-6) 100%
)
border-box;
}
/* Body: left CTA + right grid */
.agents-hero__body {
display: flex;
gap: 2rem;
align-items: flex-start;
}
/* ── Left CTA — plain content, only the button inside is interactive ── */
.agents-hero__cta {
display: flex;
flex-direction: column;
gap: 0;
flex: 0 0 18rem;
box-sizing: border-box;
padding: 0.25rem 0;
}
.agents-hero__cta-logo {
color: var(--mantine-color-blue-filled);
margin-bottom: 0.625rem;
}
.agents-hero__cta-headline {
font-size: 1.375rem;
}
.agents-hero__cta-btn {
all: unset;
margin-top: 1.25rem;
display: inline-flex;
align-items: center;
padding: 0.4375rem 1rem;
border-radius: 0.5rem;
background: var(--mantine-color-blue-6);
color: white;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1;
width: fit-content;
cursor: pointer;
transition: background 0.15s ease;
}
.agents-hero__cta-btn:hover {
background: var(--mantine-color-blue-7);
}
.agents-hero__cta-btn:focus-visible {
outline: 2px solid var(--mantine-color-blue-5);
outline-offset: 3px;
border-radius: 0.5rem;
}
/* ── Right 2×3 grid — sizes to content, pushed to the right ── */
.agents-hero__grid {
flex: 0 0 auto;
margin-left: auto;
display: grid;
/* auto columns: each column = widest item in that column */
grid-template-columns: repeat(2, auto);
gap: 0.625rem;
align-content: start;
align-self: start;
}
/* Solid white cards — no opacity so the white is opaque */
.agents-hero__grid-item {
all: unset;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0.875rem;
border: 1px solid rgba(0, 0, 0, 0.07);
border-radius: 0.75rem;
background: var(--mantine-color-body);
cursor: not-allowed;
box-sizing: border-box;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.agents-hero__grid-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--mantine-color-blue-5);
}
.agents-hero__grid-body {
min-width: 0;
flex: 1;
}
/* ── Dark mode — fullscreen ── */
[data-mantine-color-scheme="dark"] .agents-hero {
background:
linear-gradient(
125deg,
color-mix(in srgb, var(--mantine-color-blue-9) 55%, #1a1b2e) 0%,
color-mix(in srgb, var(--mantine-color-violet-9) 45%, #1a1b2e) 50%,
color-mix(in srgb, var(--mantine-color-pink-9) 40%, #1a1b2e) 100%
)
padding-box,
linear-gradient(
135deg,
var(--mantine-color-blue-6) 0%,
var(--mantine-color-violet-6) 45%,
var(--mantine-color-pink-6) 100%
)
border-box;
}
[data-mantine-color-scheme="dark"] .agents-hero__cta-logo {
color: var(--mantine-color-blue-3);
}
[data-mantine-color-scheme="dark"] .agents-hero__grid-item {
background: var(--fullscreen-bg-item);
border-color: var(--fullscreen-border-subtle-70);
box-shadow: none;
}
[data-mantine-color-scheme="dark"] .agents-hero__grid-icon {
color: var(--mantine-color-blue-3);
}
/* ─── agents-rail view transition — slide up ─────────────────────────────── */
::view-transition-old(agents-rail) {
animation: vt-agents-out 150ms ease-out forwards;
}
::view-transition-new(agents-rail) {
animation: vt-agents-in 260ms cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
}
@keyframes vt-agents-out {
to {
opacity: 0;
}
}
@keyframes vt-agents-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(agents-rail),
::view-transition-new(agents-rail) {
animation-duration: 1ms !important;
}
}
@@ -0,0 +1,290 @@
import { useState } from "react";
import type { ElementType } from "react";
import { Box, Group, Text, UnstyledButton } from "@mantine/core";
import TableChartRoundedIcon from "@mui/icons-material/TableChartRounded";
import SummarizeRoundedIcon from "@mui/icons-material/SummarizeRounded";
import VisibilityOffRoundedIcon from "@mui/icons-material/VisibilityOffRounded";
import GavelRoundedIcon from "@mui/icons-material/GavelRounded";
import AssignmentRoundedIcon from "@mui/icons-material/AssignmentRounded";
import CodeRoundedIcon from "@mui/icons-material/CodeRounded";
import { useTranslation } from "react-i18next";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useChat } from "@app/components/chat/ChatContext";
import { ChatPanel } from "@app/components/chat/ChatPanel";
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
import { withViewTransition } from "@app/utils/viewTransition";
import "@app/components/agents/AgentsPanel.css";
interface ComingSoonAgent {
id: string;
name: string;
description: string;
Icon: ElementType;
}
const COMING_SOON_AGENTS: ComingSoonAgent[] = [
{
id: "data-extraction",
name: "Data Extraction",
description: "Extract tables & structured data",
Icon: TableChartRoundedIcon,
},
{
id: "doc-summary",
name: "Summariser",
description: "Summarise long documents",
Icon: SummarizeRoundedIcon,
},
{
id: "auto-redaction",
name: "Auto Redaction",
description: "Redact PII automatically",
Icon: VisibilityOffRoundedIcon,
},
{
id: "compliance",
name: "Compliance Check",
description: "Audit documents for compliance",
Icon: GavelRoundedIcon,
},
{
id: "form-filler",
name: "Form Filler",
description: "Fill PDF forms intelligently",
Icon: AssignmentRoundedIcon,
},
{
id: "pdf-to-markdown",
name: "PDF to Markdown",
description: "Convert PDFs to clean Markdown",
Icon: CodeRoundedIcon,
},
];
export function useAgentsEnabled(): boolean {
const { config } = useAppConfig();
return Boolean(config?.aiEngineEnabled);
}
export function useAgentChatOpen(): boolean {
const { isOpen } = useChat();
return isOpen;
}
const PREVIEW_COUNT = 3;
/** Sidebar agents section — Stirling as hero CTA, coming-soon agents below. */
export function AgentsSection() {
const { t } = useTranslation();
const { isOpen, setOpen } = useChat();
const enabled = useAgentsEnabled();
const [showAll, setShowAll] = useState(false);
if (!enabled || isOpen) return null;
const comingSoonLabel = t("agents.coming_soon", "Coming soon");
const visibleAgents = showAll
? COMING_SOON_AGENTS
: COMING_SOON_AGENTS.slice(0, PREVIEW_COUNT);
return (
<Box className="agents-section" w="100%">
{/* Main Stirling agent — real and clickable */}
<UnstyledButton
className="agent-button agent-button--hero"
onClick={() => withViewTransition(() => setOpen(true))}
aria-label={t("agents.stirling_name", "Stirling")}
>
<Group gap="sm" wrap="nowrap" align="center">
<Box className="agent-button__logo">
<StirlingLogoOutline size={28} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={600} truncate>
{t("agents.stirling_name", "Stirling")}
</Text>
<Text size="xs" c="dimmed" truncate>
{t(
"agents.stirling_description",
"Your general-purpose PDF assistant",
)}
</Text>
</Box>
</Group>
</UnstyledButton>
{/* Coming-soon agents */}
<div className="agents-sidebar-list">
{visibleAgents.map(({ id, name, description, Icon }) => (
<AppTooltip
key={id}
content={comingSoonLabel}
position="left"
arrow
delay={0}
>
<UnstyledButton
className="agent-button agent-button--coming-soon"
aria-disabled="true"
tabIndex={-1}
>
<Group gap="sm" wrap="nowrap" align="center">
<Box className="agent-button__icon-plain">
<Icon sx={{ fontSize: "1.1rem" }} />
</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={500} truncate>
{name}
</Text>
<Text size="xs" c="dimmed" truncate>
{description}
</Text>
</Box>
</Group>
</UnstyledButton>
</AppTooltip>
))}
</div>
{!showAll ? (
<button
type="button"
className="agents-view-all"
onClick={() => withViewTransition(() => setShowAll(true))}
>
{t("agents.view_all", "View all agents")} →
</button>
) : (
<button
type="button"
className="agents-view-all"
onClick={() => withViewTransition(() => setShowAll(false))}
>
{t("agents.show_less", "Show less")} ↑
</button>
)}
</Box>
);
}
/** Icon-only agent button in the collapsed (minimised) right rail. */
export function AgentsCollapsedButton({ onExpand }: { onExpand: () => void }) {
const { t } = useTranslation();
const { setOpen } = useChat();
const enabled = useAgentsEnabled();
if (!enabled) return null;
const label = t("agents.stirling_tooltip", "Stirling agent");
return (
<AppTooltip content={label} position="left" arrow delay={300}>
<UnstyledButton
onClick={() => {
onExpand();
setOpen(true);
}}
aria-label={label}
className="agents-collapsed-btn"
>
<StirlingLogoOutline size={22} />
</UnstyledButton>
</AppTooltip>
);
}
/**
* Fullscreen hero card — Stirling CTA on the left, 2×3 coming-soon grid on
* the right. Matches the gradient border of the other fullscreen category cards.
*/
export function AgentsFullscreenSection() {
const { t } = useTranslation();
const { isOpen, setOpen } = useChat();
const enabled = useAgentsEnabled();
if (!enabled || isOpen) return null;
const comingSoonLabel = t("agents.coming_soon", "Coming soon");
return (
<section
className="agents-hero tool-panel__fullscreen-group--agents"
aria-label={t("agents.section_title", "Agents")}
>
<div className="agents-hero__body">
{/* Left: Stirling content — only the button is interactive */}
<div className="agents-hero__cta">
<div className="agents-hero__cta-logo">
<StirlingLogoOutline size={36} />
</div>
<Text className="agents-hero__cta-headline" fw={700} lh={1.2} mt="xs">
{t("agents.stirling_full_name", "Stirling General Agent")}
</Text>
<Text size="sm" c="dimmed" mt={8} lh={1.55}>
{t(
"agents.stirling_long_description",
"General purpose PDF assistant that can run tools, create PDFs and extract insights from your documents.",
)}
</Text>
<button
type="button"
className="agents-hero__cta-btn"
onClick={() => withViewTransition(() => setOpen(true))}
>
{t("agents.start_chat", "Start chatting")} →
</button>
</div>
{/* Right: 2×3 grid of coming-soon agents */}
<div className="agents-hero__grid">
{COMING_SOON_AGENTS.map(({ id, name, description, Icon }) => (
<AppTooltip
key={id}
content={comingSoonLabel}
position="top"
arrow
delay={0}
>
<button
type="button"
className="agents-hero__grid-item"
aria-disabled="true"
>
<span className="agents-hero__grid-icon">
<Icon sx={{ fontSize: "1rem" }} />
</span>
<div className="agents-hero__grid-body">
<Text size="sm" fw={500} truncate>
{name}
</Text>
<Text size="xs" c="dimmed" truncate>
{description}
</Text>
</div>
</button>
</AppTooltip>
))}
</div>
</div>
</section>
);
}
/** Full-rail chat overlay rendered inside ToolPanel. */
export function AgentsChatOverlay() {
const { t } = useTranslation();
const { isOpen, setOpen } = useChat();
const enabled = useAgentsEnabled();
if (!enabled || !isOpen) return null;
return (
<Box className="agents-takeover">
<ChatPanel
onBack={() => withViewTransition(() => setOpen(false))}
backLabel={t("agents.back_to_tools", "Back to tools")}
/>
</Box>
);
}
@@ -0,0 +1,21 @@
/**
* A temp stirling logo, may change in future.
*/
export function StirlingLogoOutline({ size = 20 }: { size?: number }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 192 192"
fill="currentColor"
aria-hidden="true"
>
<path
d="M68.48 102.4 L184.73 6.45 L184.73 96.05 L68.48 192 Z"
opacity="0.5"
/>
<path d="M7.26 95.83 L123.37 0 L123.37 89.5 L7.26 185.33 Z" />
</svg>
);
}
@@ -0,0 +1,572 @@
import {
createContext,
useContext,
useReducer,
useCallback,
useRef,
type ReactNode,
} from "react";
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
import apiClient from "@app/services/apiClient";
import { getAuthHeaders } from "@app/services/apiClientSetup";
import { createChildStub } from "@app/contexts/file/fileActions";
import {
createNewStirlingFileStub,
createStirlingFile,
type StirlingFileStub,
} from "@app/types/fileContext";
import type { ToolOperation } from "@app/types/file";
export interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
timestamp: number;
/**
* Tool endpoint paths executed during this assistant turn (e.g.
* {@code /api/v1/general/rotate-pdf}). Populated for assistant messages when the workflow
* ran one or more tools, in execution order. Undefined for user messages and for assistant
* turns that answered without running any tool.
*/
toolsUsed?: string[];
}
export enum AiWorkflowPhase {
ANALYZING = "analyzing",
CALLING_ENGINE = "calling_engine",
EXTRACTING_CONTENT = "extracting_content",
EXECUTING_TOOL = "executing_tool",
PROCESSING = "processing",
ENGINE_PROGRESS = "engine_progress",
}
/**
* Engine-side progress detail for ENGINE_PROGRESS events. Mirrors the Python
* {@code ProgressEvent} discriminated union (engine/src/stirling/contracts/progress.py)
* and the Java {@code AiEngineProgressDetail} sealed interface; the {@code phase}
* string is the discriminator. Field names are camelCase because the engine
* serialises by alias.
*/
export interface WholeDocReadStartedDetail {
phase: "whole_doc_read_started";
question: string;
pages: number;
slices: number;
}
export interface WholeDocSliceDoneDetail {
phase: "whole_doc_slice_done";
completed: number;
total: number;
/** Page-range label, e.g. "pages=1-5". */
pages: string;
durationMs: number;
excerpts: number;
facts: number;
}
export interface WholeDocCompressionRoundDetail {
phase: "whole_doc_compression_round";
roundNumber: number;
notesIn: number;
groups: number;
}
export interface WholeDocReadDoneDetail {
phase: "whole_doc_read_done";
completed: number;
slices: number;
durationSeconds: number;
}
export type EngineProgressDetail =
| WholeDocReadStartedDetail
| WholeDocSliceDoneDetail
| WholeDocCompressionRoundDetail
| WholeDocReadDoneDetail;
/**
* What we actually carry across the wire boundary: a known typed variant, or a
* forward-compat shape with just the discriminator string. The "unknown" arm
* exists so a new engine-side phase rolling out before a frontend update keeps
* rendering the generic processing message instead of crashing the union.
*/
export interface UnknownEngineProgressDetail {
phase: string;
}
export type AnyEngineProgressDetail =
| EngineProgressDetail
| UnknownEngineProgressDetail;
const KNOWN_ENGINE_PHASES = new Set<string>([
"whole_doc_read_started",
"whole_doc_slice_done",
"whole_doc_compression_round",
"whole_doc_read_done",
]);
export function isKnownEngineProgressDetail(
detail: AnyEngineProgressDetail,
): detail is EngineProgressDetail {
return KNOWN_ENGINE_PHASES.has(detail.phase);
}
export interface AiWorkflowProgress {
phase: AiWorkflowPhase;
/** Tool endpoint path currently executing, for EXECUTING_TOOL events. */
tool?: string;
/** 1-based step index, for EXECUTING_TOOL events. */
stepIndex?: number;
/** Total number of plan steps, for EXECUTING_TOOL events. */
stepCount?: number;
/**
* Engine-side event payload, for ENGINE_PROGRESS events. Typed sub-phase
* record (e.g. {@link WholeDocSliceDoneDetail}) the UI can render in detail.
*/
engineDetail?: AnyEngineProgressDetail;
}
type AiWorkflowOutcome =
| "answer"
| "not_found"
| "need_content"
| "plan"
| "need_clarification"
| "cannot_do"
| "tool_call"
| "completed"
| "unsupported_capability"
| "cannot_continue";
interface AiWorkflowResultFile {
/** Stirling file ID — download with /api/v1/general/files/{fileId}. */
fileId: string;
fileName: string;
contentType: string;
}
interface AiWorkflowResponse {
outcome: AiWorkflowOutcome;
answer?: string;
summary?: string;
rationale?: string;
reason?: string;
question?: string;
capability?: string;
message?: string;
evidence?: Array<{ pageNumber: number; text: string }>;
steps?: Array<Record<string, unknown>>;
/** Every file produced by the workflow (empty if the outcome has no files). */
resultFiles?: AiWorkflowResultFile[];
// Legacy single-file fields — mirror the first entry of resultFiles.
fileId?: string;
fileName?: string;
contentType?: string;
}
interface ChatState {
messages: ChatMessage[];
isOpen: boolean;
isLoading: boolean;
progress: AiWorkflowProgress | null;
}
type ChatAction =
| { type: "ADD_MESSAGE"; message: ChatMessage }
| { type: "SET_LOADING"; loading: boolean }
| { type: "SET_PROGRESS"; progress: AiWorkflowProgress | null }
| { type: "TOGGLE_OPEN" }
| { type: "SET_OPEN"; open: boolean }
| { type: "CLEAR" };
function chatReducer(state: ChatState, action: ChatAction): ChatState {
switch (action.type) {
case "ADD_MESSAGE":
return { ...state, messages: [...state.messages, action.message] };
case "SET_LOADING":
return { ...state, isLoading: action.loading };
case "SET_PROGRESS":
return { ...state, progress: action.progress };
case "TOGGLE_OPEN":
return { ...state, isOpen: !state.isOpen };
case "SET_OPEN":
return { ...state, isOpen: action.open };
case "CLEAR":
return { ...state, messages: [], isLoading: false, progress: null };
}
}
function formatWorkflowResponse(data: AiWorkflowResponse): string {
switch (data.outcome) {
case "answer":
case "completed":
return data.answer ?? data.summary ?? "Done.";
case "need_clarification":
return data.question ?? "Could you clarify your request?";
case "cannot_do":
return data.reason ?? "I'm unable to do that.";
case "not_found":
return data.reason ?? "I couldn't find the requested information.";
case "unsupported_capability":
return (
data.message ??
`Unsupported capability: ${data.capability ?? "unknown"}`
);
case "cannot_continue":
return data.reason ?? "Something went wrong and I can't continue.";
case "plan":
return data.rationale
? `${data.rationale}\n\n${(data.steps ?? []).map((s, i) => `${i + 1}. ${JSON.stringify(s)}`).join("\n")}`
: JSON.stringify(data.steps, null, 2);
case "need_content":
case "tool_call":
return (
data.rationale ?? data.summary ?? `Processing (${data.outcome})...`
);
default:
return (
data.answer ?? data.summary ?? data.message ?? JSON.stringify(data)
);
}
}
/**
* Parses an SSE text stream and invokes callbacks for each named event.
*/
interface ProgressEvent {
phase: string;
timestamp: number;
tool?: string;
stepIndex?: number;
stepCount?: number;
engineDetail?: AnyEngineProgressDetail;
}
async function consumeSSEStream(
response: Response,
handlers: {
onProgress: (data: ProgressEvent) => void;
onResult: (data: AiWorkflowResponse) => void;
onError: (data: { message: string }) => void;
},
) {
if (!response.body) {
throw new Error("Response body is null");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let currentEvent = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by double newlines
let boundary = buffer.indexOf("\n\n");
while (boundary !== -1) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
let dataPayload = "";
for (const line of frame.split("\n")) {
if (line.startsWith("event:")) {
currentEvent = line.slice(6).trim();
} else if (line.startsWith("data:")) {
dataPayload += line.slice(5);
}
}
if (dataPayload) {
try {
const parsed = JSON.parse(dataPayload);
if (currentEvent === "progress") {
handlers.onProgress(parsed);
} else if (currentEvent === "result") {
handlers.onResult(parsed);
} else if (currentEvent === "error") {
handlers.onError(parsed);
}
} catch {
// Skip malformed JSON frames
}
}
currentEvent = "";
boundary = buffer.indexOf("\n\n");
}
}
}
interface ChatContextValue {
messages: ChatMessage[];
isOpen: boolean;
isLoading: boolean;
progress: AiWorkflowProgress | null;
toggleOpen: () => void;
setOpen: (open: boolean) => void;
sendMessage: (content: string) => Promise<void>;
/** Abort any in-flight request and reset the chat to an empty conversation. */
clearChat: () => void;
}
const ChatContext = createContext<ChatContextValue | null>(null);
const initialState: ChatState = {
messages: [],
isOpen: false,
isLoading: false,
progress: null,
};
export function ChatProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(chatReducer, initialState);
const { files: activeFiles, fileStubs: activeFileStubs } = useAllFiles();
const { actions: fileActions } = useFileActions();
const abortRef = useRef<AbortController | null>(null);
const messagesRef = useRef<ChatMessage[]>(state.messages);
messagesRef.current = state.messages;
// Download a File from the Stirling files endpoint.
const downloadFile = useCallback(
async (descriptor: AiWorkflowResultFile): Promise<File> => {
const response = await apiClient.get<Blob>(
`/api/v1/general/files/${descriptor.fileId}`,
{ responseType: "blob" },
);
return new File([response.data], descriptor.fileName, {
type: descriptor.contentType ?? response.data.type,
});
},
[],
);
// Import the files produced by an AI workflow result into FileContext.
//
// If the workflow produced the same number of outputs as inputs, map each output to its
// corresponding input as a new version in the same chain. Otherwise (merge, split, etc.)
// add the outputs as new root files.
const importResultFile = useCallback(
async (
result: AiWorkflowResponse,
sourceStubs: StirlingFileStub[],
): Promise<void> => {
const descriptors = result.resultFiles?.length
? result.resultFiles
: result.fileId && result.fileName && result.contentType
? [
{
fileId: result.fileId,
fileName: result.fileName,
contentType: result.contentType,
} satisfies AiWorkflowResultFile,
]
: [];
if (descriptors.length === 0) return;
const files = await Promise.all(descriptors.map(downloadFile));
const operation: ToolOperation = {
toolId: "ai-workflow",
timestamp: Date.now(),
};
const isVersionMapping =
sourceStubs.length > 0 && files.length === sourceStubs.length;
const stubs = files.map((file, i) =>
isVersionMapping
? createChildStub(sourceStubs[i], operation, file)
: createNewStirlingFileStub(file),
);
const stirlingFiles = files.map((file, i) =>
createStirlingFile(file, stubs[i].id),
);
if (sourceStubs.length > 0) {
// Always consume the inputs so merge/split inputs are removed from the workbench.
// For 1:1 operations (rotate, compress) the outputs carry the version chain; for
// merge/split they're fresh roots.
await fileActions.consumeFiles(
sourceStubs.map((s) => s.id),
stirlingFiles,
stubs,
);
} else {
// No inputs were provided (unlikely for completed workflows, but handle it safely).
await fileActions.addFiles(files, { selectFiles: true });
}
},
[fileActions, downloadFile],
);
const toggleOpen = useCallback(() => dispatch({ type: "TOGGLE_OPEN" }), []);
const setOpen = useCallback(
(open: boolean) => dispatch({ type: "SET_OPEN", open }),
[],
);
const clearChat = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
dispatch({ type: "CLEAR" });
}, []);
const sendMessage = useCallback(
async (content: string) => {
// Abort any in-flight request
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const priorMessages = messagesRef.current;
const userMessage: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
content,
timestamp: Date.now(),
};
dispatch({ type: "ADD_MESSAGE", message: userMessage });
dispatch({ type: "SET_LOADING", loading: true });
dispatch({ type: "SET_PROGRESS", progress: null });
try {
const formData = new FormData();
formData.append("userMessage", content);
activeFiles.forEach((file, i) => {
formData.append(`fileInputs[${i}].fileInput`, file);
});
priorMessages.forEach((message, i) => {
formData.append(`conversationHistory[${i}].role`, message.role);
formData.append(`conversationHistory[${i}].content`, message.content);
});
const response = await fetch("/api/v1/ai/orchestrate/stream", {
method: "POST",
body: formData,
headers: getAuthHeaders(),
credentials: "include",
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`AI engine request failed: ${response.status}`);
}
let receivedResult = false;
const toolsUsed: string[] = [];
await consumeSSEStream(response, {
onProgress: (data) => {
if (
data.phase === AiWorkflowPhase.EXECUTING_TOOL &&
typeof data.tool === "string"
) {
toolsUsed.push(data.tool);
}
dispatch({
type: "SET_PROGRESS",
progress: {
phase: data.phase as AiWorkflowPhase,
tool: data.tool,
stepIndex: data.stepIndex,
stepCount: data.stepCount,
engineDetail: data.engineDetail,
},
});
},
onResult: (data) => {
receivedResult = true;
dispatch({ type: "SET_PROGRESS", progress: null });
const replyContent = formatWorkflowResponse(data);
dispatch({
type: "ADD_MESSAGE",
message: {
id: crypto.randomUUID(),
role: "assistant",
content: replyContent,
timestamp: Date.now(),
toolsUsed: toolsUsed.length > 0 ? toolsUsed : undefined,
},
});
if (data.fileId || data.resultFiles?.length) {
importResultFile(data, activeFileStubs).catch((err) => {
console.error("Failed to import AI result file", err);
dispatch({
type: "ADD_MESSAGE",
message: {
id: crypto.randomUUID(),
role: "assistant",
content:
"The file was processed but I couldn't download it.",
timestamp: Date.now(),
},
});
});
}
},
onError: (data) => {
receivedResult = true;
dispatch({ type: "SET_PROGRESS", progress: null });
dispatch({
type: "ADD_MESSAGE",
message: {
id: crypto.randomUUID(),
role: "assistant",
content: data.message || "Something went wrong.",
timestamp: Date.now(),
},
});
},
});
if (!receivedResult) {
throw new Error("Stream ended without a result");
}
} catch (e) {
if ((e as Error).name === "AbortError") return;
dispatch({ type: "SET_PROGRESS", progress: null });
dispatch({
type: "ADD_MESSAGE",
message: {
id: crypto.randomUUID(),
role: "assistant",
content:
"Failed to get a response. The AI engine may not be available yet.",
timestamp: Date.now(),
},
});
} finally {
dispatch({ type: "SET_LOADING", loading: false });
if (abortRef.current === controller) {
abortRef.current = null;
}
}
},
[activeFiles, activeFileStubs, importResultFile],
);
return (
<ChatContext.Provider
value={{
messages: state.messages,
isOpen: state.isOpen,
isLoading: state.isLoading,
progress: state.progress,
toggleOpen,
setOpen,
sendMessage,
clearChat,
}}
>
{children}
</ChatContext.Provider>
);
}
export function useChat(): ChatContextValue {
const context = useContext(ChatContext);
if (!context) {
throw new Error("useChat must be used within a ChatProvider");
}
return context;
}
@@ -0,0 +1,309 @@
.chat-panel {
display: flex;
flex-direction: column;
/* Match the right-rail toolbar background so dark-mode doesn't show a
lighter card behind the chat, and so the agent-card → pill morph
doesn't flash a different colour mid-transition. */
background: var(--bg-toolbar);
}
.chat-panel--embedded {
height: 100%;
width: 100%;
}
/* Top header: agent pill + close. */
.chat-panel__header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 1rem 0.75rem;
flex-shrink: 0;
}
.chat-panel__agent-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
padding: 0.4rem 0.75rem 0.4rem 0.4rem;
border: 1px solid var(--border-subtle);
border-radius: 9999px;
background: var(--mantine-color-body);
cursor: pointer;
transition:
background 120ms ease-out,
border-color 120ms ease-out;
/* Pair with .agent-button to morph between the card and the pill. */
view-transition-name: stirling-agent;
}
/* Match the active-tool morph timing for a consistent rail feel. */
::view-transition-group(stirling-agent),
::view-transition-old(stirling-agent),
::view-transition-new(stirling-agent) {
animation-duration: 220ms;
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
.chat-panel__agent-pill:hover {
background: var(--mantine-color-default-hover);
}
.chat-panel__agent-pill-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 9999px;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
flex-shrink: 0;
}
/* Dark-mode: let the pill blend into the rail. Just a thin border, no fill —
so it doesn't read as a clashing lighter card on the dark toolbar. */
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill {
background: transparent;
border-color: var(--border-subtle);
}
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill:hover {
background: rgba(255, 255, 255, 0.04);
}
[data-mantine-color-scheme="dark"] .chat-panel__agent-pill-icon {
background: color-mix(
in srgb,
var(--mantine-color-blue-filled) 18%,
transparent
);
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
}
/* Same treatment for the input pill and quick-action cards. */
[data-mantine-color-scheme="dark"] .chat-panel-input {
background: transparent;
box-shadow:
0 0 0 1px var(--border-subtle),
0 6px 16px rgba(0, 0, 0, 0.25);
}
[data-mantine-color-scheme="dark"] .chat-panel-input:focus-within {
box-shadow:
0 0 0 1px color-mix(in srgb, var(--mantine-color-blue-3) 40%, transparent),
0 8px 22px color-mix(in srgb, var(--mantine-color-blue-6) 18%, transparent);
}
[data-mantine-color-scheme="dark"] .chat-quick-action {
background: transparent;
}
[data-mantine-color-scheme="dark"] .chat-quick-action:hover {
background: rgba(255, 255, 255, 0.04);
border-color: var(--border-subtle);
}
.chat-panel__agent-pill-label {
font-size: 0.95rem;
font-weight: 600;
flex: 1;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-panel-messages {
flex: 1;
min-height: 0;
}
/* Quick action cards above the input. */
.chat-panel__quick-actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 0.75rem 0.25rem;
flex-shrink: 0;
}
.chat-panel__quick-actions-label {
font-size: 0.95rem;
font-weight: 600;
margin-top: 0.25rem;
margin-bottom: 0.1rem;
}
/* File pills shown above the quick actions when files are loaded. */
.chat-file-pills {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.chat-file-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
max-width: 11rem;
padding: 0.2rem 0.35rem 0.2rem 0.45rem;
border: 1px solid var(--mantine-color-blue-light);
border-radius: 9999px;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
font-size: 0.8rem;
font-weight: 500;
line-height: 1.1;
}
.chat-file-pill__icon {
flex-shrink: 0;
}
.chat-file-pill__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-file-pill__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
padding: 0;
border: none;
border-radius: 9999px;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.7;
transition:
background 120ms ease-out,
opacity 120ms ease-out;
}
.chat-file-pill__remove:hover {
background: var(--mantine-color-default-hover);
opacity: 1;
}
.chat-file-pill--more {
background: transparent;
border: 1px dashed var(--border-subtle);
color: var(--text-muted);
cursor: pointer;
padding: 0.2rem 0.55rem;
}
.chat-file-pill--more:hover {
background: var(--mantine-color-default-hover);
}
.chat-quick-action {
display: block;
width: 100%;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border-subtle);
border-radius: 0.65rem;
background: var(--mantine-color-body);
transition:
background 120ms ease-out,
border-color 120ms ease-out;
}
.chat-quick-action:hover {
background: var(--mantine-color-default-hover);
border-color: var(--mantine-color-default-border);
}
.chat-quick-action__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
flex-shrink: 0;
}
/* Input area: textarea on top row, action icons on bottom row. */
.chat-panel-input {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.85rem 1rem 0.75rem;
margin: 0.75rem 0.75rem 1rem;
border: none;
border-radius: 1.1rem;
background: var(--mantine-color-body);
flex-shrink: 0;
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.04),
0 4px 14px rgba(15, 23, 42, 0.08);
transition: box-shadow 160ms ease-out;
}
.chat-panel-input:focus-within {
box-shadow:
0 0 0 1px color-mix(in srgb, var(--mantine-color-blue-6) 20%, transparent),
0 6px 18px color-mix(in srgb, var(--mantine-color-blue-6) 12%, transparent);
}
/* Kill the Mantine Textarea's own border/outline — the wrapper owns the chrome. */
.chat-panel-input__field,
.chat-panel-input__field:focus,
.chat-panel-input__field:focus-visible {
font-size: 0.95rem;
padding: 0.15rem 0 !important;
background: transparent !important;
border: none !important;
outline: none !important;
box-shadow: none !important;
}
.chat-panel-input__actions {
display: flex;
align-items: center;
justify-content: space-between;
}
/* Message layout */
.chat-message {
display: flex;
}
.chat-message-user {
justify-content: flex-end;
}
.chat-message-assistant {
justify-content: flex-start;
}
/* Bubble styling */
.chat-bubble {
max-width: 85%;
}
.chat-bubble-user {
background: var(--mantine-color-blue-filled) !important;
color: white !important;
}
.chat-bubble-user * {
color: white !important;
}
.chat-bubble-assistant {
background: var(--mantine-color-default-hover) !important;
}
@@ -0,0 +1,359 @@
import {
useMemo,
useRef,
useEffect,
useState,
type KeyboardEvent,
} from "react";
import { useTranslation } from "react-i18next";
import {
ActionIcon,
Box,
Collapse,
Group,
List,
Loader,
Menu,
Paper,
ScrollArea,
Stack,
Text,
Textarea,
UnstyledButton,
} from "@mantine/core";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import CloseIcon from "@mui/icons-material/Close";
import DeleteSweepIcon from "@mui/icons-material/DeleteSweep";
import ExpandLessIcon from "@mui/icons-material/ExpandLess";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import {
useChat,
AiWorkflowPhase,
isKnownEngineProgressDetail,
type AiWorkflowProgress,
type AnyEngineProgressDetail,
} from "@app/components/chat/ChatContext";
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
import { StirlingLogoOutline } from "@app/components/agents/StirlingLogoOutline";
import { ChatQuickActions } from "@app/components/chat/ChatQuickActions";
import "@app/components/chat/ChatPanel.css";
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
/** Resolver mapping a tool endpoint path to its translated display name. */
type ToolNameResolver = (endpoint: string) => string | null;
/**
* Look up a tool's translated name from the tool catalog. The catalog's {@code operationConfig}
* exposes the full API endpoint path for each tool, so we key the lookup on the exact path that
* arrives in SSE progress events — no string parsing.
*/
function useToolNameResolver(): ToolNameResolver {
const { allTools } = useTranslatedToolCatalog();
return useMemo(() => {
const nameByEndpoint = new Map<string, string>();
Object.values(allTools).forEach((tool) => {
const endpoint = tool.operationConfig?.endpoint;
if (typeof endpoint === "string") {
nameByEndpoint.set(endpoint, tool.name);
}
});
return (endpoint: string) => nameByEndpoint.get(endpoint) ?? null;
}, [allTools]);
}
function formatProgress(
progress: AiWorkflowProgress,
t: TranslateFn,
resolveToolName: ToolNameResolver,
): string {
if (progress.phase === AiWorkflowPhase.EXECUTING_TOOL && progress.tool) {
const tool = resolveToolName(progress.tool);
const hasSteps =
progress.stepIndex != null &&
progress.stepCount != null &&
progress.stepCount > 1;
if (tool) {
return hasSteps
? t("chat.progress.executing_tool_step", {
tool,
step: progress.stepIndex,
total: progress.stepCount,
})
: t("chat.progress.executing_tool_single", { tool });
}
return hasSteps
? t("chat.progress.executing_tool_generic_step", {
step: progress.stepIndex,
total: progress.stepCount,
})
: t("chat.progress.executing_tool_generic");
}
if (progress.phase === AiWorkflowPhase.ENGINE_PROGRESS) {
return formatEngineProgress(progress.engineDetail, t);
}
return t(`chat.progress.${progress.phase}`);
}
function formatEngineProgress(
detail: AnyEngineProgressDetail | undefined,
t: TranslateFn,
): string {
if (!detail || !isKnownEngineProgressDetail(detail)) {
return t("chat.progress.processing");
}
switch (detail.phase) {
case "whole_doc_read_started":
return t("chat.progress.whole_doc_read_started");
case "whole_doc_slice_done": {
const percent =
detail.total > 0
? Math.round((detail.completed / detail.total) * 100)
: 0;
return t("chat.progress.whole_doc_slice_done", { percent });
}
case "whole_doc_compression_round":
return t("chat.progress.whole_doc_compression_round");
case "whole_doc_read_done":
return t("chat.progress.whole_doc_read_done");
}
}
function ToolsUsedBlock({
tools,
resolveToolName,
t,
}: {
tools: string[];
resolveToolName: ToolNameResolver;
t: TranslateFn;
}) {
const [expanded, setExpanded] = useState(false);
const names = tools.map(
(endpoint) => resolveToolName(endpoint) ?? t("chat.toolsUsed.unknownTool"),
);
const label = t("chat.toolsUsed.summary", { count: tools.length });
return (
<Box mt={6}>
<UnstyledButton
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
<Group gap={4} wrap="nowrap">
{expanded ? (
<ExpandLessIcon sx={{ fontSize: 14 }} />
) : (
<ExpandMoreIcon sx={{ fontSize: 14 }} />
)}
<Text size="xs" c="dimmed">
{label}
</Text>
</Group>
</UnstyledButton>
<Collapse in={expanded}>
<List
type="ordered"
size="xs"
mt={4}
pl="lg"
styles={{ itemWrapper: { lineHeight: 1.4 } }}
>
{names.map((name, i) => (
<List.Item key={i}>{name}</List.Item>
))}
</List>
</Collapse>
</Box>
);
}
function ChatMessageBubble({
role,
content,
toolsUsed,
resolveToolName,
t,
}: {
role: "user" | "assistant";
content: string;
toolsUsed?: string[];
resolveToolName: ToolNameResolver;
t: TranslateFn;
}) {
return (
<div className={`chat-message chat-message-${role}`}>
<Paper className={`chat-bubble chat-bubble-${role}`} p="xs" radius="md">
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{content}
</Text>
{toolsUsed && toolsUsed.length > 0 && (
<ToolsUsedBlock
tools={toolsUsed}
resolveToolName={resolveToolName}
t={t}
/>
)}
</Paper>
</div>
);
}
export interface ChatPanelProps {
/** Called when the user closes the chat to return to the tool list. */
onBack: () => void;
/** Accessible label for the close button. */
backLabel: string;
}
export function ChatPanel({ onBack, backLabel }: ChatPanelProps) {
const { t } = useTranslation();
const { messages, isLoading, progress, sendMessage, clearChat } = useChat();
const resolveToolName = useToolNameResolver();
const [input, setInput] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}
}, [messages]);
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleSend = (override?: string) => {
const text = (override ?? input).trim();
if (!text || isLoading) return;
setInput("");
sendMessage(text);
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const showQuickActions = messages.length === 0 && !isLoading;
return (
<Box className="chat-panel chat-panel--embedded">
<div className="chat-panel__header">
<Menu shadow="md" width={220} position="bottom-start" withinPortal>
<Menu.Target>
<button
type="button"
className="chat-panel__agent-pill"
aria-label={t("chat.header.agentMenu", "Stirling agent options")}
>
<span className="chat-panel__agent-pill-icon">
<StirlingLogoOutline size={16} />
</span>
<span className="chat-panel__agent-pill-label">
{t("agents.stirling_name", "Stirling")}
</span>
<KeyboardArrowDownIcon
sx={{ fontSize: 18, color: "var(--text-muted)" }}
/>
</button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<DeleteSweepIcon sx={{ fontSize: 18 }} />}
onClick={clearChat}
disabled={messages.length === 0 && !isLoading}
>
{t("chat.header.clearChat", "Clear chat")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<ActionIcon
variant="subtle"
color="gray"
size="md"
radius="xl"
onClick={onBack}
aria-label={backLabel}
>
<CloseIcon sx={{ fontSize: 18 }} />
</ActionIcon>
</div>
<ScrollArea className="chat-panel-messages" viewportRef={scrollRef}>
<Stack gap="sm" p="sm">
{messages.map((msg) => (
<ChatMessageBubble
key={msg.id}
role={msg.role}
content={msg.content}
toolsUsed={msg.toolsUsed}
resolveToolName={resolveToolName}
t={t}
/>
))}
{isLoading && (
<div className="chat-message chat-message-assistant">
<Paper
className="chat-bubble chat-bubble-assistant"
p="xs"
radius="md"
>
<Group gap="xs" wrap="nowrap">
<Loader size="xs" type="dots" />
<Text size="sm" c="dimmed">
{progress
? formatProgress(progress, t, resolveToolName)
: t("chat.progress.thinking")}
</Text>
</Group>
</Paper>
</div>
)}
</Stack>
</ScrollArea>
{showQuickActions && (
<ChatQuickActions
heading={t("chat.quickActions.heading", "Get started")}
onAction={(text) => handleSend(text)}
/>
)}
<div className="chat-panel-input">
<Textarea
ref={inputRef}
placeholder={t("chat.input.placeholder", "What do you want to do?")}
value={input}
onChange={(e) => setInput(e.currentTarget.value)}
onKeyDown={handleKeyDown}
disabled={isLoading}
autosize
minRows={1}
maxRows={4}
variant="unstyled"
classNames={{ input: "chat-panel-input__field" }}
/>
<div className="chat-panel-input__actions">
<ActionIcon
variant="filled"
color="blue"
radius="xl"
size="md"
onClick={() => handleSend()}
disabled={!input.trim() || isLoading}
aria-label={t("chat.input.send", "Send message")}
>
<ArrowUpwardIcon sx={{ fontSize: 16 }} />
</ActionIcon>
</div>
</div>
</Box>
);
}
@@ -0,0 +1,303 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Box, Group, Stack, Text, UnstyledButton } from "@mantine/core";
import CloseIcon from "@mui/icons-material/Close";
import CompressIcon from "@mui/icons-material/Compress";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFileOutlined";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import LayersIcon from "@mui/icons-material/Layers";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import { useAllFiles, useFileActions } from "@app/contexts/FileContext";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { detectFileExtension, isPdfFile } from "@app/utils/fileUtils";
import type { StirlingFileStub } from "@app/types/fileContext";
const MAX_FILE_PILLS = 3;
interface QuickAction {
key: string;
icon: React.ReactNode;
title: string;
subtitle?: string;
onClick: () => void;
}
function QuickActionCard({ action }: { action: QuickAction }) {
return (
<UnstyledButton
className="chat-quick-action"
onClick={action.onClick}
aria-label={action.title}
>
<Group gap="sm" wrap="nowrap" align="center">
<Box className="chat-quick-action__icon">{action.icon}</Box>
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={500}>
{action.title}
</Text>
{action.subtitle && (
<Text size="xs" c="dimmed" truncate>
{action.subtitle}
</Text>
)}
</Box>
<KeyboardArrowDownIcon
sx={{
fontSize: 18,
transform: "rotate(-90deg)",
color: "var(--text-muted)",
}}
/>
</Group>
</UnstyledButton>
);
}
function WorkbenchFilePills({
stubs,
onOpenFilesModal,
onRemove,
moreLabel,
removeLabel,
}: {
stubs: StirlingFileStub[];
onOpenFilesModal: () => void;
onRemove: (id: StirlingFileStub["id"]) => void;
moreLabel: (count: number) => string;
removeLabel: (name: string) => string;
}) {
const visible = stubs.slice(0, MAX_FILE_PILLS);
const overflow = Math.max(0, stubs.length - visible.length);
return (
<div className="chat-file-pills">
{visible.map((stub) => (
<span key={stub.id} className="chat-file-pill">
<InsertDriveFileIcon
className="chat-file-pill__icon"
sx={{ fontSize: 14 }}
/>
<span className="chat-file-pill__label" title={stub.name}>
{stub.name}
</span>
<button
type="button"
className="chat-file-pill__remove"
onClick={() => onRemove(stub.id)}
aria-label={removeLabel(stub.name)}
>
<CloseIcon sx={{ fontSize: 12 }} />
</button>
</span>
))}
{overflow > 0 && (
<UnstyledButton
className="chat-file-pill chat-file-pill--more"
onClick={onOpenFilesModal}
>
{moreLabel(overflow)}
</UnstyledButton>
)}
</div>
);
}
interface WorkbenchSummary {
fileCount: number;
pdfCount: number;
nonPdfCount: number;
hasNonPdf: boolean;
singleFilePageCount: number | null;
typeBreakdown: { label: string; count: number }[];
}
function summariseWorkbench(stubs: StirlingFileStub[]): WorkbenchSummary {
const counts = new Map<string, number>();
let pdfCount = 0;
let nonPdfCount = 0;
for (const stub of stubs) {
const ext = detectFileExtension(stub.name ?? "");
const isPdf = isPdfFile({ name: stub.name, type: stub.type });
if (isPdf) pdfCount += 1;
else nonPdfCount += 1;
const label = ext ? ext.toUpperCase() : "FILE";
counts.set(label, (counts.get(label) ?? 0) + 1);
}
const typeBreakdown = Array.from(counts.entries())
.sort((a, b) => b[1] - a[1])
.map(([label, count]) => ({ label, count }));
return {
fileCount: stubs.length,
pdfCount,
nonPdfCount,
hasNonPdf: nonPdfCount > 0,
singleFilePageCount:
stubs.length === 1 ? (stubs[0].processedFile?.totalPages ?? null) : null,
typeBreakdown,
};
}
export interface ChatQuickActionsProps {
/** Heading text shown above the actions. */
heading: string;
/** Invoked when the user selects an action — sends the given text as a chat message. */
onAction: (text: string) => void;
}
export function ChatQuickActions({ heading, onAction }: ChatQuickActionsProps) {
const { t } = useTranslation();
const { fileStubs } = useAllFiles();
const { actions: fileActions } = useFileActions();
const { openFilesModal } = useFilesModalContext();
const summary = useMemo(() => summariseWorkbench(fileStubs), [fileStubs]);
const actions = useMemo<QuickAction[]>(() => {
const send = (text: string) => () => onAction(text);
if (summary.fileCount === 0) {
return [
{
key: "open-from-computer",
icon: <UploadFileIcon sx={{ fontSize: 18 }} />,
title: t("chat.quickActions.openFromComputer", "Open from computer"),
subtitle: t("chat.quickActions.browseYourFiles", "Browse your files"),
onClick: () => openFilesModal(),
},
];
}
if (summary.fileCount === 1) {
// Non-PDF: only suggest converting to PDF.
if (summary.hasNonPdf) {
const text = t(
"chat.quickActions.convertOne",
"Convert this document to PDF",
);
return [
{
key: "convert",
icon: <PictureAsPdfIcon sx={{ fontSize: 18 }} />,
title: text,
onClick: send(text),
},
];
}
const result: QuickAction[] = [];
const hasMultiplePages =
summary.singleFilePageCount != null && summary.singleFilePageCount > 1;
if (hasMultiplePages) {
const text = t("chat.quickActions.splitOne", "Split this document");
result.push({
key: "split",
icon: <ContentCutIcon sx={{ fontSize: 18 }} />,
title: text,
onClick: send(text),
});
}
const compressText = t(
"chat.quickActions.compressOne",
"Compress this document",
);
const rotateText = t(
"chat.quickActions.rotateOne",
"Rotate this document",
);
result.push({
key: "compress",
icon: <CompressIcon sx={{ fontSize: 18 }} />,
title: compressText,
onClick: send(compressText),
});
result.push({
key: "rotate",
icon: <RotateRightIcon sx={{ fontSize: 18 }} />,
title: rotateText,
onClick: send(rotateText),
});
return result;
}
// Multiple files.
const result: QuickAction[] = [];
if (summary.hasNonPdf) {
const text = t(
"chat.quickActions.convertMany",
"Convert these documents to PDF",
);
result.push({
key: "convert",
icon: <PictureAsPdfIcon sx={{ fontSize: 18 }} />,
title: text,
onClick: send(text),
});
}
const mergeText = t("chat.quickActions.mergeMany", {
count: summary.fileCount,
defaultValue: "Merge these {{count}} documents into 1",
});
const compressText = t(
"chat.quickActions.compressMany",
"Compress these documents",
);
const rotateText = t(
"chat.quickActions.rotateMany",
"Rotate these documents",
);
result.push({
key: "merge",
icon: <LayersIcon sx={{ fontSize: 18 }} />,
title: mergeText,
onClick: send(mergeText),
});
result.push({
key: "compress",
icon: <CompressIcon sx={{ fontSize: 18 }} />,
title: compressText,
onClick: send(compressText),
});
result.push({
key: "rotate",
icon: <RotateRightIcon sx={{ fontSize: 18 }} />,
title: rotateText,
onClick: send(rotateText),
});
return result;
}, [summary, t, onAction, openFilesModal]);
return (
<div className="chat-panel__quick-actions">
{summary.fileCount > 0 && (
<WorkbenchFilePills
stubs={fileStubs}
onOpenFilesModal={() => openFilesModal()}
onRemove={(id) => fileActions.removeFiles([id])}
moreLabel={(count) =>
t("chat.quickActions.moreFiles", {
count,
defaultValue: "+{{count}} more",
})
}
removeLabel={(name) =>
t("chat.quickActions.removeFile", {
name,
defaultValue: "Remove {{name}}",
})
}
/>
)}
<Text className="chat-panel__quick-actions-label">{heading}</Text>
<Stack gap="xs">
{actions.map((action) => (
<QuickActionCard key={action.key} action={action} />
))}
</Stack>
</div>
);
}
@@ -8,5 +8,8 @@ import { type ProprietaryToolRegistry } from "@app/data/toolsTaxonomy";
* and will be included in the main tool registry.
*/
export function useProprietaryToolRegistry(): ProprietaryToolRegistry {
return useMemo<ProprietaryToolRegistry>(() => ({}), []);
// "ai-workflow" is a marker super-tool (see proprietaryToolId.ts) with no
// registry entry, so we widen via `as`. Add real proprietary tools here as
// `{ id: { ... } }` entries.
return useMemo(() => ({}) as ProprietaryToolRegistry, []);
}
@@ -6,7 +6,11 @@
export const PROPRIETARY_REGULAR_TOOL_IDS = [] as const;
export const PROPRIETARY_SUPER_TOOL_IDS = [] as const;
// "ai-workflow" is a generic marker stamped onto files produced by the agents
// chat orchestrator (which may invoke one or more underlying tools). Lives here
// as a super-tool so ``ToolOperation.toolId`` stays typed; not user-pickable —
// see ChatContext.tsx. The tool registry doesn't include it as an entry.
export const PROPRIETARY_SUPER_TOOL_IDS = ["ai-workflow"] as const;
export const PROPRIETARY_LINK_TOOL_IDS = [] as const;
@@ -1,20 +0,0 @@
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
import { type AppProvidersProps } from "@core/components/AppProviders";
import { ChatProvider } from "@app/components/chat/ChatContext";
export type { AppProvidersProps };
export function AppProviders({
children,
appConfigRetryOptions,
appConfigProviderProps,
}: AppProvidersProps) {
return (
<ProprietaryAppProviders
appConfigRetryOptions={appConfigRetryOptions}
appConfigProviderProps={appConfigProviderProps}
>
<ChatProvider>{children}</ChatProvider>
</ProprietaryAppProviders>
);
}
@@ -1,77 +0,0 @@
.chat-toggle-button {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.chat-panel {
position: fixed;
top: 0;
right: 0;
width: 380px;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--mantine-color-body);
border-left: 1px solid
var(--border-subtle, var(--mantine-color-default-border));
z-index: 999;
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
}
.chat-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid
var(--border-subtle, var(--mantine-color-default-border));
flex-shrink: 0;
}
.chat-panel-messages {
flex: 1;
min-height: 0;
}
.chat-panel-input {
display: flex;
gap: 0.5rem;
padding: 0.75rem;
border-top: 1px solid
var(--border-subtle, var(--mantine-color-default-border));
flex-shrink: 0;
}
/* Message layout */
.chat-message {
display: flex;
}
.chat-message-user {
justify-content: flex-end;
}
.chat-message-assistant {
justify-content: flex-start;
}
/* Bubble styling */
.chat-bubble {
max-width: 85%;
}
.chat-bubble-user {
background: var(--mantine-color-blue-filled) !important;
color: white !important;
}
.chat-bubble-user * {
color: white !important;
}
.chat-bubble-assistant {
background: var(--mantine-color-default-hover) !important;
}
@@ -1,5 +0,0 @@
import { ChatPanel } from "@app/components/chat/ChatPanel";
export function HomePageExtensions() {
return <ChatPanel />;
}
+219
View File
@@ -0,0 +1,219 @@
// Auto-generated icon types
// This file is automatically generated by scripts/generate-icons.js
// Do not edit manually - changes will be overwritten
export type MaterialSymbolIcon =
| "123-rounded"
| "account-circle"
| "add"
| "add-comment"
| "add-photo-alternate"
| "add-rounded"
| "admin-panel-settings"
| "admin-panel-settings-rounded"
| "analytics"
| "analytics-rounded"
| "api-rounded"
| "approval-outline-rounded"
| "arrow-back"
| "arrow-back-rounded"
| "arrow-forward"
| "article-outline-rounded"
| "assignment-outline-rounded"
| "attachment-rounded"
| "auto-awesome-rounded"
| "automation-outline"
| "backup"
| "bookmark-add-rounded"
| "branding-watermark-outline-rounded"
| "brush"
| "build-outline-rounded"
| "change-history"
| "check"
| "check-circle"
| "check-circle-outline-rounded"
| "check-circle-rounded"
| "chevron-left"
| "chevron-left-rounded"
| "chevron-right-rounded"
| "cleaning-services-outline-rounded"
| "close"
| "close-rounded"
| "cloud-upload"
| "comment"
| "compare-rounded"
| "compress"
| "content-copy"
| "content-copy-rounded"
| "content-cut-rounded"
| "content-paste-rounded"
| "credit-card"
| "crop-free-rounded"
| "crop-rounded"
| "crop-square"
| "crop-square-outline"
| "dashboard-customize-outline-rounded"
| "dashboard-customize-rounded"
| "dashboard-outline-rounded"
| "delete"
| "delete-outline-rounded"
| "delete-rounded"
| "description"
| "desktop-cloud-rounded"
| "download"
| "download-rounded"
| "edit"
| "edit-outline-rounded"
| "edit-rounded"
| "edit-square-outline-rounded"
| "edit-square-rounded"
| "error"
| "error-outline-rounded"
| "error-rounded"
| "expand-more-rounded"
| "explore-rounded"
| "extension-rounded"
| "fact-check-outline-rounded"
| "fact-check-rounded"
| "find-replace"
| "folder-rounded"
| "format-align-center"
| "format-align-left"
| "format-align-right"
| "format-color-fill-rounded"
| "format-underlined"
| "gavel-rounded"
| "gpp-maybe-outline-rounded"
| "group"
| "group-add"
| "group-rounded"
| "groups-rounded"
| "help-rounded"
| "highlight"
| "image-outline-rounded"
| "info"
| "info-outline-rounded"
| "info-rounded"
| "javascript-rounded"
| "key"
| "key-rounded"
| "keyboard-arrow-down"
| "keyboard-arrow-up"
| "keyboard-rounded"
| "language"
| "layers-clear-outline-rounded"
| "layers-outline-rounded"
| "library-add-outline-rounded"
| "link"
| "link-rounded"
| "lock"
| "lock-open"
| "lock-open-right-outline-rounded"
| "lock-outline"
| "login"
| "logout"
| "logout-rounded"
| "looks-one-outline-rounded"
| "mail-rounded"
| "match-word-rounded"
| "material-symbols:pause-rounded"
| "material-symbols:play-arrow-rounded"
| "menu-book-outline-rounded"
| "menu-book-rounded"
| "more-vert"
| "move-down-rounded"
| "opacity"
| "open-in-new-rounded"
| "palette-outline"
| "pan-tool-rounded"
| "password-rounded"
| "pause-rounded"
| "person"
| "person-add"
| "person-add-rounded"
| "person-check"
| "person-off"
| "person-remove"
| "person-rounded"
| "photo-library-outline-rounded"
| "picture-as-pdf-rounded"
| "pin-end"
| "play-arrow-rounded"
| "play-circle"
| "preview-off-rounded"
| "qr-code-rounded"
| "quick-reference-all-outline-rounded"
| "radio-button-unchecked"
| "redo"
| "refresh"
| "refresh-rounded"
| "remove-moderator-outline-rounded"
| "remove-selection-rounded"
| "rocket-launch"
| "rotate-left"
| "rotate-right"
| "rotate-right-rounded"
| "save"
| "save-rounded"
| "scan-delete-outline-rounded"
| "scan-delete-rounded"
| "scanner-outline-rounded"
| "schedule-outline-rounded"
| "search"
| "search-rounded"
| "security"
| "select-all"
| "send-rounded"
| "settings-rounded"
| "shield-lock"
| "shield-rounded"
| "show-chart"
| "signature-rounded"
| "speed"
| "star"
| "star-rounded"
| "sticky-note-2"
| "storage-rounded"
| "strikethrough-s"
| "subdirectory-arrow-right-rounded"
| "swap-horiz"
| "sync-alt-rounded"
| "system-update-alt-rounded"
| "text-fields"
| "text-fields-rounded"
| "thread-unread-rounded"
| "toc-rounded"
| "touch-app-rounded"
| "tune-rounded"
| "undo"
| "unfold-less"
| "unfold-more"
| "upload-file-rounded"
| "upload-rounded"
| "verified-outline-rounded"
| "verified-user-outline"
| "verified-user-rounded"
| "view-carousel-rounded"
| "view-list"
| "visibility"
| "visibility-off-outline-rounded"
| "visibility-rounded"
| "vital-signs-rounded"
| "warning"
| "warning-rounded"
| "workspace-premium-outline-rounded"
| "workspace-premium-rounded"
| "zoom-in"
| "zoom-in-map-rounded"
| "zoom-out";
export interface IconSet {
prefix: string;
icons: Record<string, any>;
width?: number;
height?: number;
}
// Re-export the icon set as the default export with proper typing
declare const iconSet: IconSet;
export default iconSet;
@@ -0,0 +1,622 @@
{
"prefix": "material-symbols",
"icons": {
"123-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5.5 10.5h-.75q-.325 0-.537-.213T4 9.75t.213-.537T4.75 9h1.5q.325 0 .538.213T7 9.75v4.5q0 .325-.213.538T6.25 15t-.537-.213t-.213-.537zM9 14.25V12.5q0-.425.288-.712T10 11.5h2v-1H9.75q-.325 0-.537-.213T9 9.75t.213-.537T9.75 9h2.75q.425 0 .713.288T13.5 10v1.5q0 .425-.288.713t-.712.287h-2v1h2.25q.325 0 .538.213t.212.537t-.213.538t-.537.212h-3q-.325 0-.537-.213T9 14.25m9.5.75h-2.75q-.325 0-.537-.213T15 14.25t.213-.537t.537-.213H18v-1h-1.5q-.2 0-.35-.15T16 12t.15-.35t.35-.15H18v-1h-2.25q-.325 0-.537-.213T15 9.75t.213-.537T15.75 9h2.75q.425 0 .713.288T19.5 10v4q0 .425-.288.713T18.5 15\"/>"
},
"account-circle": {
"body": "<path fill=\"currentColor\" d=\"M5.85 17.1q1.275-.975 2.85-1.537T12 15t3.3.563t2.85 1.537q.875-1.025 1.363-2.325T20 12q0-3.325-2.337-5.663T12 4T6.337 6.338T4 12q0 1.475.488 2.775T5.85 17.1m3.663-5.113Q8.5 10.976 8.5 9.5t1.013-2.488T12 6t2.488 1.013T15.5 9.5t-1.012 2.488T12 13t-2.488-1.012M12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22\"/>"
},
"add": {
"body": "<path fill=\"currentColor\" d=\"M11 13H5v-2h6V5h2v6h6v2h-6v6h-2z\"/>"
},
"add-comment": {
"body": "<path fill=\"currentColor\" d=\"M11 14h2v-3h3V9h-3V6h-2v3H8v2h3zm-9 8V4q0-.825.588-1.412T4 2h16q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18H6z\"/>"
},
"add-photo-alternate": {
"body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h9q-.5.65-.75 1.425T13 6q0 2.075 1.463 3.538T18 11q.8 0 1.575-.25T21 10v9q0 .825-.587 1.413T19 21zm1-4h12l-3.75-5l-3 4L9 13zm11-8V7h-2V5h2V3h2v2h2v2h-2v2z\"/>"
},
"add-rounded": {
"body": "<path fill=\"currentColor\" d=\"M11 13H6q-.425 0-.712-.288T5 12t.288-.712T6 11h5V6q0-.425.288-.712T12 5t.713.288T13 6v5h5q.425 0 .713.288T19 12t-.288.713T18 13h-5v5q0 .425-.288.713T12 19t-.712-.288T11 18z\"/>"
},
"admin-panel-settings": {
"body": "<path fill=\"currentColor\" d=\"M17 22q-2.075 0-3.537-1.463T12 17t1.463-3.537T17 12t3.538 1.463T22 17t-1.463 3.538T17 22m-5 0q-3.475-.875-5.738-3.988T4 11.1V5l8-3l8 3v5.675q-.65-.325-1.463-.5T17 10q-2.9 0-4.95 2.05T10 17q0 1.55.588 2.8t1.487 2.175q-.025 0-.037.013T12 22m6.063-5.437q.437-.438.437-1.063t-.437-1.062T17 14t-1.062.438T15.5 15.5t.438 1.063T17 17t1.063-.437M17 20q.775 0 1.425-.363t1.05-.962q-.55-.325-1.175-.5T17 18t-1.3.175t-1.175.5q.4.6 1.05.963T17 20\"/>"
},
"admin-panel-settings-rounded": {
"body": "<path fill=\"currentColor\" d=\"M17 22q-2.075 0-3.537-1.463T12 17t1.463-3.537T17 12t3.538 1.463T22 17t-1.463 3.538T17 22M4 11.1V6.375q0-.625.363-1.125t.937-.725l6-2.25q.35-.125.7-.125t.7.125l6 2.25q.575.225.938.725T20 6.375v3.05q0 .425-.375.688t-.8.137q-.45-.125-.9-.187T17 10q-2.9 0-4.95 2.05T10 17q0 .8.188 1.563t.537 1.537q.225.475-.137.85t-.838.15q-1.05-.55-1.925-1.35T6.275 18Q5.2 16.525 4.6 14.762T4 11.1m14.063 5.463q.437-.438.437-1.063t-.437-1.062T17 14t-1.062.438T15.5 15.5t.438 1.063T17 17t1.063-.437M17 20q.625 0 1.175-.238t.975-.687q.125-.15.1-.337t-.225-.288q-.475-.225-.987-.337T17 18t-1.037.113t-.988.337q-.2.1-.225.288t.1.337q.425.45.975.688T17 20\"/>"
},
"analytics": {
"body": "<path fill=\"currentColor\" d=\"M7 17h2v-5H7zm8 0h2V7h-2zm-4 0h2v-3h-2zm0-5h2v-2h-2zm-6 9q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21z\"/>"
},
"analytics-rounded": {
"body": "<path fill=\"currentColor\" d=\"M7.288 12.288Q7 12.575 7 13v3q0 .425.288.713T8 17t.713-.288T9 16v-3q0-.425-.288-.712T8 12t-.712.288m8-5Q15 7.575 15 8v8q0 .425.288.713T16 17t.713-.288T17 16V8q0-.425-.288-.712T16 7t-.712.288m-4 7Q11 14.575 11 15v1q0 .425.288.713T12 17t.713-.288T13 16v-1q0-.425-.288-.712T12 14t-.712.288M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm7.713-9.287Q13 11.425 13 11t-.288-.712T12 10t-.712.288T11 11t.288.713T12 12t.713-.288\"/>"
},
"api-rounded": {
"body": "<path fill=\"currentColor\" d=\"m12 14l-2-2l2-2l2 2zM9.875 8.125l-2.5-2.5l3.2-3.2q.3-.3.675-.45t.75-.15t.75.15t.675.45l3.2 3.2l-2.5 2.5L12 6zm-4.25 8.5l-3.2-3.2q-.3-.3-.45-.675t-.15-.75t.15-.75t.45-.675l3.2-3.2l2.5 2.5L6 12l2.125 2.125zm12.75 0l-2.5-2.5L18 12l-2.125-2.125l2.5-2.5l3.2 3.2q.3.3.45.675t.15.75t-.15.75t-.45.675zm-7.8 4.95l-3.2-3.2l2.5-2.5L12 18l2.125-2.125l2.5 2.5l-3.2 3.2q-.3.3-.675.45t-.75.15t-.75-.15t-.675-.45\"/>"
},
"approval-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M6 22q-.825 0-1.412-.587T4 20v-4q0-.825.588-1.412T6 14h12q.825 0 1.413.588T20 16v4q0 .825-.587 1.413T18 22zm1-4h10q.425 0 .713-.288T18 17t-.288-.712T17 16H7q-.425 0-.712.288T6 17t.288.713T7 18m4.175-5.15l-3.7-5.175q-.225-.325-.312-.712t-.038-.788q.3-1.825 1.65-3T12 2t3.225 1.175t1.65 3q.05.4-.038.788t-.312.712l-3.7 5.175q-.3.425-.825.425t-.825-.425M12 11.2L15 7q0-1.25-.875-2.125T12 4t-2.125.875T9 7zm0-3.6\"/>"
},
"arrow-back": {
"body": "<path fill=\"currentColor\" d=\"m7.825 13l5.6 5.6L12 20l-8-8l8-8l1.425 1.4l-5.6 5.6H20v2z\"/>"
},
"arrow-back-rounded": {
"body": "<path fill=\"currentColor\" d=\"m7.825 13l4.9 4.9q.3.3.288.7t-.313.7q-.3.275-.7.288t-.7-.288l-6.6-6.6q-.15-.15-.213-.325T4.426 12t.063-.375t.212-.325l6.6-6.6q.275-.275.688-.275t.712.275q.3.3.3.713t-.3.712L7.825 11H19q.425 0 .713.288T20 12t-.288.713T19 13z\"/>"
},
"arrow-forward": {
"body": "<path fill=\"currentColor\" d=\"M16.175 13H4v-2h12.175l-5.6-5.6L12 4l8 8l-8 8l-1.425-1.4z\"/>"
},
"article-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5H5zM5 5v14zm3 12h5q.425 0 .713-.288T14 16t-.288-.712T13 15H8q-.425 0-.712.288T7 16t.288.713T8 17m0-4h8q.425 0 .713-.288T17 12t-.288-.712T16 11H8q-.425 0-.712.288T7 12t.288.713T8 13m0-4h8q.425 0 .713-.288T17 8t-.288-.712T16 7H8q-.425 0-.712.288T7 8t.288.713T8 9\"/>"
},
"assignment-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h4.2q.325-.9 1.088-1.45T12 1t1.713.55T14.8 3H19q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5H5zm3-2h5q.425 0 .713-.288T14 16t-.288-.712T13 15H8q-.425 0-.712.288T7 16t.288.713T8 17m0-4h8q.425 0 .713-.288T17 12t-.288-.712T16 11H8q-.425 0-.712.288T7 12t.288.713T8 13m0-4h8q.425 0 .713-.288T17 8t-.288-.712T16 7H8q-.425 0-.712.288T7 8t.288.713T8 9m4.538-4.962q.212-.213.212-.538t-.213-.537T12 2.75t-.537.213t-.213.537t.213.538t.537.212t.538-.213M5 19V5z\"/>"
},
"attachment-rounded": {
"body": "<path fill=\"currentColor\" d=\"M8.25 18q-2.6 0-4.425-1.825T2 11.75t1.825-4.425T8.25 5.5h9.25q1.875 0 3.188 1.313T22 10t-1.312 3.188T17.5 14.5H8.75q-1.15 0-1.95-.8T6 11.75t.8-1.95T8.75 9H17q.425 0 .713.288T18 10t-.288.713T17 11H8.75q-.325 0-.537.213T8 11.75t.213.538t.537.212h8.75q1.05-.025 1.775-.737T20 10t-.725-1.775T17.5 7.5H8.25q-1.775-.025-3.012 1.225T4 11.75q0 1.75 1.238 2.975T8.25 16H17q.425 0 .713.288T18 17t-.288.713T17 18z\"/>"
},
"auto-awesome-rounded": {
"body": "<path fill=\"currentColor\" d=\"M19 8.3q-.125 0-.263-.075T18.55 8l-.8-1.75l-1.75-.8q-.15-.05-.225-.187T15.7 5q0-.125.075-.262T16 4.55l1.75-.8l.8-1.75q.05-.15.188-.225T19 1.7q.125 0 .263.075T19.45 2l.8 1.75l1.75.8q.15.05.225.188T22.3 5q0 .125-.075.263T22 5.45l-1.75.8l-.8 1.75q-.05.15-.188.225T19 8.3Zm0 14q-.125 0-.263-.075T18.55 22l-.8-1.75l-1.75-.8q-.15-.05-.225-.188T15.7 19q0-.125.075-.263T16 18.55l1.75-.8l.8-1.75q.05-.15.188-.225T19 15.7q.125 0 .263.075t.187.225l.8 1.75l1.75.8q.15.05.225.188T22.3 19q0 .125-.075.263T22 19.45l-1.75.8l-.8 1.75q-.05.15-.188.225T19 22.3ZM9 18.575q-.275 0-.525-.15T8.1 18l-1.6-3.5L3 12.9q-.275-.125-.425-.375T2.425 12q0-.275.15-.525T3 11.1l3.5-1.6L8.1 6q.125-.275.375-.425T9 5.425q.275 0 .525.15T9.9 6l1.6 3.5l3.5 1.6q.275.125.425.375t.15.525q0 .275-.15.525T15 12.9l-3.5 1.6L9.9 18q-.125.275-.375.425t-.525.15Z\"/>",
"hidden": true
},
"automation-outline": {
"body": "<path fill=\"currentColor\" d=\"M7.4 17.25q-1.05.875-2.187.8t-1.988-.775t-1.162-1.837t.412-2.338L4.35 10q-.625-.55-.987-1.325T3 7q0-1.65 1.175-2.825T7 3t2.825 1.175T11 7T9.825 9.825T7 11q-.225 0-.45-.025t-.425-.075L4.2 14.15q-.275.45-.175.888t.425.712t.775.313t.875-.313l10.5-9.025q1.05-.875 2.2-.788t2 .788t1.15 1.838t-.425 2.337L19.65 14q.625.55.988 1.325T21 17q0 1.65-1.175 2.825T17 21t-2.825-1.175T13 17t1.175-2.825T17 13q.225 0 .438.025t.412.075l1.95-3.25q.275-.45.175-.888t-.425-.712t-.775-.312t-.875.312zm1.013-8.837Q9 7.825 9 7t-.587-1.412T7 5t-1.412.588T5 7t.588 1.413T7 9t1.413-.587m10 10Q19 17.825 19 17t-.587-1.412T17 15t-1.412.588T15 17t.588 1.413T17 19t1.413-.587M17 17\"/>"
},
"backup": {
"body": "<path fill=\"currentColor\" d=\"M11 20H6.5q-2.275 0-3.887-1.575T1 14.575q0-1.95 1.175-3.475T5.25 9.15q.625-2.3 2.5-3.725T12 4q2.925 0 4.963 2.038T19 11q1.725.2 2.863 1.488T23 15.5q0 1.875-1.312 3.188T18.5 20H13v-7.15l1.6 1.55L16 13l-4-4l-4 4l1.4 1.4l1.6-1.55z\"/>"
},
"bookmark-add-rounded": {
"body": "<path fill=\"currentColor\" d=\"M17 7h-1q-.425 0-.712-.288T15 6t.288-.712T16 5h1V4q0-.425.288-.712T18 3t.713.288T19 4v1h1q.425 0 .713.288T21 6t-.288.713T20 7h-1v1q0 .425-.288.713T18 9t-.712-.288T17 8zm-5 11l-4.2 1.8q-1 .425-1.9-.162T5 17.975V5q0-.825.588-1.412T7 3h5.625q.45 0 .675.4t.025.825q-.175.425-.25.85T13 6q0 1.8 1.138 3.175T17 10.9q.3.05.538.063t.462.012q.425 0 .713.263T19 11.9v6.075q0 1.075-.9 1.663t-1.9.162z\"/>"
},
"branding-watermark-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M11 17h7q.425 0 .713-.288T19 16v-4q0-.425-.288-.712T18 11h-7q-.425 0-.712.288T10 12v4q0 .425.288.713T11 17m-7 3q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm0-2h16V6H4zm0 0V6z\"/>"
},
"brush": {
"body": "<path fill=\"currentColor\" d=\"M6 21q-1.125 0-2.225-.55T2 19q.65 0 1.325-.513T4 17q0-1.25.875-2.125T7 14t2.125.875T10 17q0 1.65-1.175 2.825T6 21m5.75-6L9 12.25l8.95-8.95q.275-.275.688-.288t.712.288l1.35 1.35q.3.3.3.7t-.3.7z\"/>"
},
"build-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M9 15q-2.5 0-4.25-1.75T3 9q0-.5.075-1t.275-.95q.125-.25.313-.375t.412-.175t.463.013t.437.262L7.6 9.4l1.8-1.8l-2.625-2.625q-.2-.2-.262-.437T6.5 4.075t.175-.413t.375-.312q.45-.2.95-.275T9 3q2.5 0 4.25 1.75T15 9q0 .575-.1 1.088t-.3 1.012l5.05 5q.725.725.725 1.775t-.725 1.775t-1.775.725t-1.775-.75l-5-5.025q-.5.2-1.012.3T9 15m0-2q.65 0 1.3-.2t1.175-.625l6.075 6.075q.125.125.338.113t.337-.138t.125-.337t-.125-.338L12.15 11.5q.45-.5.65-1.162T13 9q0-1.5-.962-2.613T9.65 5.05L11.5 6.9q.3.3.3.7t-.3.7l-3.2 3.2q-.3.3-.7.3t-.7-.3L5.05 9.65q.225 1.425 1.338 2.388T9 13m2.725-1.275\"/>"
},
"change-history": {
"body": "<path fill=\"currentColor\" d=\"M2 20L12 4l10 16z\"/>"
},
"check": {
"body": "<path fill=\"currentColor\" d=\"m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z\"/>"
},
"check-circle": {
"body": "<path fill=\"currentColor\" d=\"m10.6 16.6l7.05-7.05l-1.4-1.4l-5.65 5.65l-2.85-2.85l-1.4 1.4zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22\"/>"
},
"check-circle-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"m10.6 13.8l-2.15-2.15q-.275-.275-.7-.275t-.7.275t-.275.7t.275.7L9.9 15.9q.3.3.7.3t.7-.3l5.65-5.65q.275-.275.275-.7t-.275-.7t-.7-.275t-.7.275zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4T6.325 6.325T4 12t2.325 5.675T12 20m0-8\"/>"
},
"check-circle-rounded": {
"body": "<path fill=\"currentColor\" d=\"m10.6 13.8l-2.15-2.15q-.275-.275-.7-.275t-.7.275t-.275.7t.275.7L9.9 15.9q.3.3.7.3t.7-.3l5.65-5.65q.275-.275.275-.7t-.275-.7t-.7-.275t-.7.275zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22\"/>"
},
"chevron-left": {
"body": "<path fill=\"currentColor\" d=\"m14 18l-6-6l6-6l1.4 1.4l-4.6 4.6l4.6 4.6z\"/>"
},
"chevron-left-rounded": {
"body": "<path fill=\"currentColor\" d=\"m10.8 12l3.9 3.9q.275.275.275.7t-.275.7t-.7.275t-.7-.275l-4.6-4.6q-.15-.15-.212-.325T8.425 12t.063-.375t.212-.325l4.6-4.6q.275-.275.7-.275t.7.275t.275.7t-.275.7z\"/>"
},
"chevron-right-rounded": {
"body": "<path fill=\"currentColor\" d=\"M12.6 12L8.7 8.1q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275l4.6 4.6q.15.15.213.325t.062.375t-.062.375t-.213.325l-4.6 4.6q-.275.275-.7.275t-.7-.275t-.275-.7t.275-.7z\"/>"
},
"cleaning-services-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5 23q-.825 0-1.412-.587T3 21v-5q0-2.075 1.463-3.537T8 11h1V3q0-.825.588-1.412T11 1h2q.825 0 1.413.588T15 3v8h1q2.075 0 3.538 1.463T21 16v5q0 .825-.587 1.413T19 23zm0-2h2v-3q0-.425.288-.712T8 17t.713.288T9 18v3h2v-3q0-.425.288-.712T12 17t.713.288T13 18v3h2v-3q0-.425.288-.712T16 17t.713.288T17 18v3h2v-5q0-1.25-.875-2.125T16 13H8q-1.25 0-2.125.875T5 16zm8-10V3h-2v8zm0 0h-2z\"/>"
},
"close": {
"body": "<path fill=\"currentColor\" d=\"M6.4 19L5 17.6l5.6-5.6L5 6.4L6.4 5l5.6 5.6L17.6 5L19 6.4L13.4 12l5.6 5.6l-1.4 1.4l-5.6-5.6z\"/>"
},
"close-rounded": {
"body": "<path fill=\"currentColor\" d=\"m12 13.4l-4.9 4.9q-.275.275-.7.275t-.7-.275t-.275-.7t.275-.7l4.9-4.9l-4.9-4.9q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275l4.9 4.9l4.9-4.9q.275-.275.7-.275t.7.275t.275.7t-.275.7L13.4 12l4.9 4.9q.275.275.275.7t-.275.7t-.7.275t-.7-.275z\"/>"
},
"comment": {
"body": "<path fill=\"currentColor\" d=\"M6 14h12v-2H6zm0-3h12V9H6zm0-3h12V6H6zM4 18q-.825 0-1.412-.587T2 16V4q0-.825.588-1.412T4 2h16q.825 0 1.413.588T22 4v18l-4-4z\"/>"
},
"compare-rounded": {
"body": "<path fill=\"currentColor\" d=\"M10 21H5q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h5V2q0-.425.288-.712T11 1t.713.288T12 2v20q0 .425-.288.713T11 23t-.712-.288T10 22zm-5-3h5v-6zm9 3v-9l5 6V5h-5V3h5q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21z\"/>"
},
"compress": {
"body": "<path fill=\"currentColor\" d=\"M4 14v-2h16v2zm0-3V9h16v2zm7 11v-3.2l-1.6 1.6L8 19l4-4l4 4l-1.4 1.4l-1.6-1.55V22zm1-14L8 4l1.4-1.4L11 4.2V1h2v3.2l1.6-1.6L16 4z\"/>"
},
"content-copy": {
"body": "<path fill=\"currentColor\" d=\"M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z\"/>"
},
"content-copy-rounded": {
"body": "<path fill=\"currentColor\" d=\"M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V7q0-.425.288-.712T4 6t.713.288T5 7v13h10q.425 0 .713.288T16 21t-.288.713T15 22z\"/>"
},
"content-cut-rounded": {
"body": "<path fill=\"currentColor\" d=\"m12 14l-2.35 2.35q.2.375.275.8T10 18q0 1.65-1.175 2.825T6 22t-2.825-1.175T2 18t1.175-2.825T6 14q.425 0 .85.075t.8.275L10 12L7.65 9.65q-.375.2-.8.275T6 10q-1.65 0-2.825-1.175T2 6t1.175-2.825T6 2t2.825 1.175T10 6q0 .425-.075.85t-.275.8L20.6 18.6q.675.675.3 1.538T19.575 21q-.275 0-.537-.112t-.463-.313zm3-3l-2-2l5.575-5.575q.2-.2.463-.312T19.574 3q.95 0 1.313.875t-.313 1.55zM7.413 7.413Q8 6.825 8 6t-.587-1.412T6 4t-1.412.588T4 6t.588 1.413T6 8t1.413-.587m4.937 4.937q.15-.15.15-.35t-.15-.35t-.35-.15t-.35.15t-.15.35t.15.35t.35.15t.35-.15m-4.937 7.063Q8 18.825 8 18t-.587-1.412T6 16t-1.412.588T4 18t.588 1.413T6 20t1.413-.587\"/>"
},
"content-paste-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h4.175q.275-.875 1.075-1.437T12 1q1 0 1.788.563T14.85 3H19q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5h-2v2q0 .425-.288.713T16 8H8q-.425 0-.712-.288T7 7V5H5zm7.713-14.288Q13 4.425 13 4t-.288-.712T12 3t-.712.288T11 4t.288.713T12 5t.713-.288\"/>"
},
"credit-card": {
"body": "<path fill=\"currentColor\" d=\"M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm0-8h16V8H4z\"/>"
},
"crop-free-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.412-.587T3 19v-3q0-.425.288-.712T4 15t.713.288T5 16v3h3q.425 0 .713.288T9 20t-.288.713T8 21zm14 0h-3q-.425 0-.712-.288T15 20t.288-.712T16 19h3v-3q0-.425.288-.712T20 15t.713.288T21 16v3q0 .825-.587 1.413T19 21M3 8V5q0-.825.588-1.412T5 3h3q.425 0 .713.288T9 4t-.288.713T8 5H5v3q0 .425-.288.713T4 9t-.712-.288T3 8m16 0V5h-3q-.425 0-.712-.288T15 4t.288-.712T16 3h3q.825 0 1.413.588T21 5v3q0 .425-.288.713T20 9t-.712-.288T19 8\"/>"
},
"crop-rounded": {
"body": "<path fill=\"currentColor\" d=\"M17 22v-3H7q-.825 0-1.412-.587T5 17V7H2q-.425 0-.712-.288T1 6t.288-.712T2 5h3V2q0-.425.288-.712T6 1t.713.288T7 2v15h15q.425 0 .713.288T23 18t-.288.713T22 19h-3v3q0 .425-.288.713T18 23t-.712-.288T17 22m0-7V7H9V5h8q.825 0 1.413.588T19 7v8z\"/>"
},
"square-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21z\"/>"
},
"crop-square-outline": {
"body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.413-.587Q3 19.825 3 19V5q0-.825.587-1.413Q4.175 3 5 3h14q.825 0 1.413.587Q21 4.175 21 5v14q0 .825-.587 1.413Q19.825 21 19 21Zm0-2h14V5H5v14Zm0 0V5v14Z\"/>",
"hidden": true
},
"dashboard-customize-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M4 11q-.425 0-.712-.288T3 10V4q0-.425.288-.712T4 3h6q.425 0 .713.288T11 4v6q0 .425-.288.713T10 11zm1-6v4zm9 6q-.425 0-.712-.288T13 10V4q0-.425.288-.712T14 3h6q.425 0 .713.288T21 4v6q0 .425-.288.713T20 11zm1-6v4zM4 21q-.425 0-.712-.288T3 20v-6q0-.425.288-.712T4 13h6q.425 0 .713.288T11 14v6q0 .425-.288.713T10 21zm1-6v4zm12 6q-.425 0-.712-.288T16 20v-2h-2.025q-.425 0-.7-.288T13 17t.288-.712T14 16h2v-2.025q0-.425.288-.7T17 13t.713.288T18 14v2h2.025q.425 0 .7.288T21 17t-.288.713T20 18h-2v2.025q0 .425-.288.7T17 21M15 5v4h4V5zM5 5v4h4V5zm0 10v4h4v-4z\"/>"
},
"dashboard-customize-rounded": {
"body": "<path fill=\"currentColor\" d=\"M4 11q-.425 0-.712-.288T3 10V4q0-.425.288-.712T4 3h6q.425 0 .713.288T11 4v6q0 .425-.288.713T10 11zm10 0q-.425 0-.712-.288T13 10V4q0-.425.288-.712T14 3h6q.425 0 .713.288T21 4v6q0 .425-.288.713T20 11zM4 21q-.425 0-.712-.288T3 20v-6q0-.425.288-.712T4 13h6q.425 0 .713.288T11 14v6q0 .425-.288.713T10 21zm13 0q-.425 0-.712-.288T16 20v-2h-2.025q-.425 0-.7-.288T13 17t.288-.712T14 16h2v-2.025q0-.425.288-.7T17 13t.713.288T18 14v2h2.025q.425 0 .7.288T21 17t-.288.713T20 18h-2v2.025q0 .425-.288.7T17 21\"/>"
},
"dashboard-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M13 8V4q0-.425.288-.712T14 3h6q.425 0 .713.288T21 4v4q0 .425-.288.713T20 9h-6q-.425 0-.712-.288T13 8M3 12V4q0-.425.288-.712T4 3h6q.425 0 .713.288T11 4v8q0 .425-.288.713T10 13H4q-.425 0-.712-.288T3 12m10 8v-8q0-.425.288-.712T14 11h6q.425 0 .713.288T21 12v8q0 .425-.288.713T20 21h-6q-.425 0-.712-.288T13 20M3 20v-4q0-.425.288-.712T4 15h6q.425 0 .713.288T11 16v4q0 .425-.288.713T10 21H4q-.425 0-.712-.288T3 20m2-9h4V5H5zm10 8h4v-6h-4zm0-12h4V5h-4zM5 19h4v-2H5zm4-2\"/>"
},
"delete": {
"body": "<path fill=\"currentColor\" d=\"M7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21zm2-4h2V8H9zm4 0h2V8h-2z\"/>"
},
"delete-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M7 21q-.825 0-1.412-.587T5 19V6q-.425 0-.712-.288T4 5t.288-.712T5 4h4q0-.425.288-.712T10 3h4q.425 0 .713.288T15 4h4q.425 0 .713.288T20 5t-.288.713T19 6v13q0 .825-.587 1.413T17 21zM17 6H7v13h10zm-6.287 10.713Q11 16.425 11 16V9q0-.425-.288-.712T10 8t-.712.288T9 9v7q0 .425.288.713T10 17t.713-.288m4 0Q15 16.426 15 16V9q0-.425-.288-.712T14 8t-.712.288T13 9v7q0 .425.288.713T14 17t.713-.288M7 6v13z\"/>"
},
"delete-rounded": {
"body": "<path fill=\"currentColor\" d=\"M7 21q-.825 0-1.412-.587T5 19V6q-.425 0-.712-.288T4 5t.288-.712T5 4h4q0-.425.288-.712T10 3h4q.425 0 .713.288T15 4h4q.425 0 .713.288T20 5t-.288.713T19 6v13q0 .825-.587 1.413T17 21zm3.713-4.288Q11 16.426 11 16V9q0-.425-.288-.712T10 8t-.712.288T9 9v7q0 .425.288.713T10 17t.713-.288m4 0Q15 16.426 15 16V9q0-.425-.288-.712T14 8t-.712.288T13 9v7q0 .425.288.713T14 17t.713-.288\"/>"
},
"description": {
"body": "<path fill=\"currentColor\" d=\"M8 18h8v-2H8zm0-4h8v-2H8zm-2 8q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h8l6 6v12q0 .825-.587 1.413T18 22zm7-13h5l-5-5z\"/>"
},
"desktop-cloud-rounded": {
"body": "<path fill=\"currentColor\" d=\"M9 14h6.5q1.05 0 1.775-.737T18 11.474T17.25 9.7t-1.8-.725q-.2-1.275-1.175-2.125T12 6q-1.025 0-1.875.55T8.85 8.025q-1.2.05-2.025.913T6 11q0 1.25.875 2.125T9 14m1 5v-2H4q-.825 0-1.412-.587T2 15V5q0-.825.588-1.412T4 3h16q.825 0 1.413.588T22 5v10q0 .825-.587 1.413T20 17h-6v2h1q.425 0 .713.288T16 20t-.288.713T15 21H9q-.425 0-.712-.288T8 20t.288-.712T9 19z\"/>"
},
"download": {
"body": "<path fill=\"currentColor\" d=\"m12 16l-5-5l1.4-1.45l2.6 2.6V4h2v8.15l2.6-2.6L17 11zm-6 4q-.825 0-1.412-.587T4 18v-3h2v3h12v-3h2v3q0 .825-.587 1.413T18 20z\"/>"
},
"download-rounded": {
"body": "<path fill=\"currentColor\" d=\"M11.625 15.513q-.175-.063-.325-.213l-3.6-3.6q-.3-.3-.288-.7t.288-.7q.3-.3.713-.312t.712.287L11 12.15V5q0-.425.288-.712T12 4t.713.288T13 5v7.15l1.875-1.875q.3-.3.713-.288t.712.313q.275.3.288.7t-.288.7l-3.6 3.6q-.15.15-.325.213t-.375.062t-.375-.062M6 20q-.825 0-1.412-.587T4 18v-2q0-.425.288-.712T5 15t.713.288T6 16v2h12v-2q0-.425.288-.712T19 15t.713.288T20 16v2q0 .825-.587 1.413T18 20z\"/>"
},
"edit": {
"body": "<path fill=\"currentColor\" d=\"M3 21v-4.25L16.2 3.575q.3-.275.663-.425t.762-.15t.775.15t.65.45L20.425 5q.3.275.438.65T21 6.4q0 .4-.137.763t-.438.662L7.25 21zM17.6 7.8L19 6.4L17.6 5l-1.4 1.4z\"/>"
},
"edit-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5 19h1.425L16.2 9.225L14.775 7.8L5 17.575zm-1 2q-.425 0-.712-.288T3 20v-2.425q0-.4.15-.763t.425-.637L16.2 3.575q.3-.275.663-.425t.762-.15t.775.15t.65.45L20.425 5q.3.275.437.65T21 6.4q0 .4-.138.763t-.437.662l-12.6 12.6q-.275.275-.638.425t-.762.15zM19 6.4L17.6 5zm-3.525 2.125l-.7-.725L16.2 9.225z\"/>"
},
"edit-rounded": {
"body": "<path fill=\"currentColor\" d=\"M4 21q-.425 0-.712-.288T3 20v-2.425q0-.4.15-.763t.425-.637L16.2 3.575q.3-.275.663-.425t.762-.15t.775.15t.65.45L20.425 5q.3.275.437.65T21 6.4q0 .4-.138.763t-.437.662l-12.6 12.6q-.275.275-.638.425t-.762.15zM17.6 7.8L19 6.4L17.6 5l-1.4 1.4z\"/>"
},
"edit-square-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h6.525q.5 0 .75.313t.25.687t-.262.688T11.5 5H5v14h14v-6.525q0-.5.313-.75t.687-.25t.688.25t.312.75V19q0 .825-.587 1.413T19 21zm4-7v-2.425q0-.4.15-.763t.425-.637l8.6-8.6q.3-.3.675-.45t.75-.15q.4 0 .763.15t.662.45L22.425 3q.275.3.425.663T23 4.4t-.137.738t-.438.662l-8.6 8.6q-.275.275-.637.438t-.763.162H10q-.425 0-.712-.288T9 14m12.025-9.6l-1.4-1.4zM11 13h1.4l5.8-5.8l-.7-.7l-.725-.7L11 11.575zm6.5-6.5l-.725-.7zl.7.7z\"/>"
},
"edit-square-rounded": {
"body": "<path fill=\"currentColor\" d=\"M10 15q-.425 0-.712-.288T9 14v-2.425q0-.4.15-.763t.425-.637l8.6-8.6q.3-.3.675-.45t.75-.15q.4 0 .763.15t.662.45L22.425 3q.275.3.425.663T23 4.4t-.137.738t-.438.662l-8.6 8.6q-.275.275-.637.438t-.763.162zm9.6-9.2l1.425-1.4l-1.4-1.4L18.2 4.4zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h6.5q.35 0 .575.175t.35.45t.087.55t-.287.525l-4.65 4.65q-.275.275-.425.638T7 10.75V15q0 .825.588 1.412T9 17h4.225q.4 0 .763-.15t.637-.425L19.3 11.75q.25-.25.525-.288t.55.088t.45.35t.175.575V19q0 .825-.587 1.413T19 21z\"/>"
},
"error": {
"body": "<path fill=\"currentColor\" d=\"M12.713 16.713Q13 16.425 13 16t-.288-.712T12 15t-.712.288T11 16t.288.713T12 17t.713-.288M11 13h2V7h-2zm1 9q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22\"/>"
},
"error-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M12.713 16.713Q13 16.425 13 16t-.288-.712T12 15t-.712.288T11 16t.288.713T12 17t.713-.288m0-4Q13 12.425 13 12V8q0-.425-.288-.712T12 7t-.712.288T11 8v4q0 .425.288.713T12 13t.713-.288M12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4T6.325 6.325T4 12t2.325 5.675T12 20m0-8\"/>"
},
"error-rounded": {
"body": "<path fill=\"currentColor\" d=\"M12.713 16.713Q13 16.425 13 16t-.288-.712T12 15t-.712.288T11 16t.288.713T12 17t.713-.288m0-4Q13 12.425 13 12V8q0-.425-.288-.712T12 7t-.712.288T11 8v4q0 .425.288.713T12 13t.713-.288M12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22\"/>"
},
"expand-more-rounded": {
"body": "<path fill=\"currentColor\" d=\"M12 14.95q-.2 0-.375-.062t-.325-.213l-4.6-4.6q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275l3.9 3.9l3.9-3.9q.275-.275.7-.275t.7.275t.275.7t-.275.7l-4.6 4.6q-.15.15-.325.213T12 14.95\"/>",
"hidden": true
},
"explore-rounded": {
"body": "<path fill=\"currentColor\" d=\"m8.375 16.25l5.05-1.45q.5-.15.863-.513t.512-.862l1.45-5.05q.075-.275-.137-.488t-.488-.137l-5.05 1.45q-.5.15-.862.513t-.513.862l-1.45 5.05q-.075.275.138.488t.487.137M12 13.5q-.625 0-1.062-.437T10.5 12t.438-1.062T12 10.5t1.063.438T13.5 12t-.437 1.063T12 13.5m0 8.5q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22\"/>"
},
"extension-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.413-.587Q3 19.825 3 19v-3.8q1.2 0 2.1-.762q.9-.763.9-1.938q0-1.175-.9-1.938Q4.2 9.8 3 9.8V6q0-.825.587-1.412Q4.175 4 5 4h4q0-1.05.725-1.775Q10.45 1.5 11.5 1.5q1.05 0 1.775.725Q14 2.95 14 4h4q.825 0 1.413.588Q20 5.175 20 6v4q1.05 0 1.775.725q.725.725.725 1.775q0 1.05-.725 1.775Q21.05 15 20 15v4q0 .825-.587 1.413Q18.825 21 18 21h-3.8q0-1.2-.762-2.1q-.763-.9-1.938-.9q-1.175 0-1.938.9q-.762.9-.762 2.1Z\"/>",
"hidden": true
},
"fact-check-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M4 21q-.825 0-1.412-.587T2 19V5q0-.825.588-1.412T4 3h16q.825 0 1.413.588T22 5v14q0 .825-.587 1.413T20 21zm0-2h16V5H4zm5-2q.425 0 .713-.288T10 16t-.288-.712T9 15H6q-.425 0-.712.288T5 16t.288.713T6 17zm5.55-4.825l-.725-.725q-.3-.3-.7-.287t-.7.312q-.275.3-.288.7t.288.7L13.85 14.3q.3.3.7.3t.7-.3l3.55-3.55q.3-.3.3-.7t-.3-.7t-.712-.3t-.713.3zM9 13q.425 0 .713-.288T10 12t-.288-.712T9 11H6q-.425 0-.712.288T5 12t.288.713T6 13zm0-4q.425 0 .713-.288T10 8t-.288-.712T9 7H6q-.425 0-.712.288T5 8t.288.713T6 9zM4 19V5z\"/>"
},
"fact-check-rounded": {
"body": "<path fill=\"currentColor\" d=\"M4 21q-.825 0-1.412-.587T2 19V5q0-.825.588-1.412T4 3h16q.825 0 1.413.588T22 5v14q0 .825-.587 1.413T20 21zm5-4q.425 0 .713-.288T10 16t-.288-.712T9 15H6q-.425 0-.712.288T5 16t.288.713T6 17zm5.55-4.825l-.725-.725q-.3-.3-.7-.287t-.7.312q-.275.3-.288.7t.288.7L13.85 14.3q.3.3.7.3t.7-.3l3.55-3.55q.3-.3.3-.7t-.3-.7t-.712-.3t-.713.3zM9 13q.425 0 .713-.288T10 12t-.288-.712T9 11H6q-.425 0-.712.288T5 12t.288.713T6 13zm0-4q.425 0 .713-.288T10 8t-.288-.712T9 7H6q-.425 0-.712.288T5 8t.288.713T6 9z\"/>"
},
"find-replace": {
"body": "<path fill=\"currentColor\" d=\"M4.1 10q.35-2.575 2.288-4.288T11 4q1.475 0 2.763.563T16 6.1V4h2v6h-6V8h3q-.725-.9-1.737-1.45T11 6Q9.2 6 7.825 7.138T6.1 10zm15.5 11l-4.4-4.4q-.9.675-1.962 1.038T11 18q-1.475 0-2.762-.562T6 15.9V18H4v-6h6v2H7q.725.9 1.738 1.45T11 16q1.8 0 3.175-1.137T15.9 12h2q-.125.9-.45 1.688T16.6 15.2l4.4 4.4z\"/>"
},
"folder-rounded": {
"body": "<path fill=\"currentColor\" d=\"M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h5.175q.4 0 .763.15t.637.425L12 6h8q.825 0 1.413.588T22 8v10q0 .825-.587 1.413T20 20z\"/>"
},
"format-align-center": {
"body": "<path fill=\"currentColor\" d=\"M3 21v-2h18v2zm4-4v-2h10v2zm-4-4v-2h18v2zm4-4V7h10v2zM3 5V3h18v2z\"/>"
},
"format-align-left": {
"body": "<path fill=\"currentColor\" d=\"M3 21v-2h18v2zm0-4v-2h12v2zm0-4v-2h18v2zm0-4V7h12v2zm0-4V3h18v2z\"/>"
},
"format-align-right": {
"body": "<path fill=\"currentColor\" d=\"M3 5V3h18v2zm6 4V7h12v2zm-6 4v-2h18v2zm6 4v-2h12v2zm-6 4v-2h18v2z\"/>"
},
"format-color-fill-rounded": {
"body": "<path fill=\"currentColor\" d=\"m8.3.7l7.875 7.875q.575.575.575 1.425t-.575 1.425l-4.75 4.75q-.575.575-1.425.575t-1.425-.575l-4.75-4.75Q3.25 10.85 3.25 10t.575-1.425L8.575 3.8l-1.7-1.7q-.3-.3-.288-.7T6.9.7q.3-.275.7-.287T8.3.7M10 5.225L5.225 10h9.55zm7.588 11.188Q17 15.825 17 15q0-.525.313-1.125T18 12.75q.225-.3.475-.625T19 11.5q.275.3.525.625t.475.625q.375.525.688 1.125T21 15q0 .825-.587 1.413T19 17t-1.412-.587M4 24q-.825 0-1.412-.587T2 22t.588-1.412T4 20h16q.825 0 1.413.588T22 22t-.587 1.413T20 24z\"/>"
},
"format-underlined": {
"body": "<path fill=\"currentColor\" d=\"M5 21v-2h14v2zm3.075-5.575q-1.4-1.575-1.4-4.175V3H9.25v8.4q0 1.4.7 2.275t2.05.875t2.05-.875t.7-2.275V3h2.575v8.25q0 2.6-1.4 4.175T12 17t-3.925-1.575\"/>"
},
"gavel-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5 19h10q.425 0 .713.288T16 20t-.288.713T15 21H5q-.425 0-.712-.288T4 20t.288-.712T5 19m3.225-4.275L5.4 11.9q-.575-.575-.587-1.412t.562-1.413L6.1 8.35L11.8 14l-.725.725q-.575.575-1.425.575t-1.425-.575M16 9.8l-5.65-5.7l.725-.725q.575-.575 1.413-.562T13.9 3.4l2.825 2.825q.575.575.575 1.425t-.575 1.425zm3.9 9.5L7.55 6.95l1.4-1.4L21.3 17.9q.275.275.275.7t-.275.7t-.7.275t-.7-.275\"/>"
},
"gpp-maybe-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M12.713 15.713Q13 15.425 13 15t-.288-.712T12 14t-.712.288T11 15t.288.713T12 16t.713-.288m0-4Q13 11.426 13 11V8q0-.425-.288-.712T12 7t-.712.288T11 8v3q0 .425.288.713T12 12t.713-.288m-1.038 10.163q-.15-.025-.3-.075Q8 20.675 6 17.637T4 11.1V6.375q0-.625.363-1.125t.937-.725l6-2.25q.35-.125.7-.125t.7.125l6 2.25q.575.225.938.725T20 6.375V11.1q0 3.5-2 6.538T12.625 21.8q-.15.05-.3.075T12 21.9t-.325-.025M12 19.9q2.6-.825 4.3-3.3t1.7-5.5V6.375l-6-2.25l-6 2.25V11.1q0 3.025 1.7 5.5t4.3 3.3m0-7.9\"/>"
},
"group": {
"body": "<path fill=\"currentColor\" d=\"M1 20v-2.8q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2V20zm18 0v-3q0-1.1-.612-2.113T16.65 13.15q1.275.15 2.4.513t2.1.887q.9.5 1.375 1.112T23 17v3zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175m11.65 0Q16.65 12 15 12q-.275 0-.7-.062t-.7-.138q.675-.8 1.038-1.775T15 8t-.362-2.025T13.6 4.2q.35-.125.7-.163T15 4q1.65 0 2.825 1.175T19 8t-1.175 2.825\"/>"
},
"group-add": {
"body": "<path fill=\"currentColor\" d=\"M12.5 11.95q.725-.8 1.113-1.825T14 8t-.387-2.125T12.5 4.05q1.5.2 2.5 1.325T16 8t-1 2.625t-2.5 1.325M18 20v-3q0-.9-.4-1.713t-1.05-1.437q1.275.45 2.363 1.163T20 17v3zm2-7v-2h-2V9h2V7h2v2h2v2h-2v2zM5.175 10.825Q4 9.65 4 8t1.175-2.825T8 4t2.825 1.175T12 8t-1.175 2.825T8 12t-2.825-1.175M0 20v-2.8q0-.85.438-1.562T1.6 14.55q1.55-.775 3.15-1.162T8 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T16 17.2V20z\"/>"
},
"group-rounded": {
"body": "<path fill=\"currentColor\" d=\"M1 17.2q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2v.8q0 .825-.587 1.413T15 20H3q-.825 0-1.412-.587T1 18zM18.45 20q.275-.45.413-.962T19 18v-1q0-1.1-.612-2.113T16.65 13.15q1.275.15 2.4.513t2.1.887q.9.5 1.375 1.112T23 17v1q0 .825-.587 1.413T21 20zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175m11.65 0Q16.65 12 15 12q-.275 0-.7-.062t-.7-.138q.675-.8 1.038-1.775T15 8t-.362-2.025T13.6 4.2q.35-.125.7-.163T15 4q1.65 0 2.825 1.175T19 8t-1.175 2.825\"/>"
},
"groups-rounded": {
"body": "<path fill=\"currentColor\" d=\"M1 18q-.425 0-.712-.288T0 17v-.575q0-1.075 1.1-1.75T4 14q.325 0 .625.013t.575.062q-.35.525-.525 1.1t-.175 1.2V18zm6 0q-.425 0-.712-.288T6 17v-.625q0-.8.438-1.463t1.237-1.162T9.588 13T12 12.75q1.325 0 2.438.25t1.912.75t1.225 1.163t.425 1.462V17q0 .425-.287.713T17 18zm12.5 0v-1.625q0-.65-.162-1.225t-.488-1.075q.275-.05.563-.062T20 14q1.8 0 2.9.663t1.1 1.762V17q0 .425-.288.713T23 18zM4 13q-.825 0-1.412-.587T2 11q0-.85.588-1.425T4 9q.85 0 1.425.575T6 11q0 .825-.575 1.413T4 13m16 0q-.825 0-1.412-.587T18 11q0-.85.588-1.425T20 9q.85 0 1.425.575T22 11q0 .825-.575 1.413T20 13m-8-1q-1.25 0-2.125-.875T9 9q0-1.275.875-2.137T12 6q1.275 0 2.138.863T15 9q0 1.25-.862 2.125T12 12\"/>"
},
"help-rounded": {
"body": "<path fill=\"currentColor\" d=\"M12.838 17.638q.362-.363.362-.888t-.362-.888t-.888-.362t-.887.363t-.363.887t.363.888t.887.362t.888-.363M12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22m.1-14.3q.625 0 1.088.4t.462 1q0 .55-.337.975t-.763.8q-.575.5-1.012 1.1t-.438 1.35q0 .35.263.588t.612.237q.375 0 .638-.25t.337-.625q.1-.525.45-.937t.75-.788q.575-.55.988-1.2t.412-1.45q0-1.275-1.037-2.087T12.1 6q-.95 0-1.812.4T8.975 7.625q-.175.3-.112.638t.337.512q.35.2.725.125t.625-.425q.275-.375.688-.575t.862-.2\"/>"
},
"highlight": {
"body": "<path fill=\"currentColor\" d=\"M5.65 8L3.5 5.9l1.4-1.45L7.05 6.6zM11 5V2h2v3zm7.4 3l-1.45-1.4l2.15-2.1l1.4 1.4zM9 22v-5l-3-3V9h12v5l-3 3v5z\"/>"
},
"image-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5H5zm0 0V5zm2-2h10q.3 0 .45-.275t-.05-.525l-2.75-3.675q-.15-.2-.4-.2t-.4.2L11.25 16L9.4 13.525q-.15-.2-.4-.2t-.4.2l-2 2.675q-.2.25-.05.525T7 17\"/>"
},
"info": {
"body": "<path fill=\"currentColor\" d=\"M11 17h2v-6h-2zm1.713-8.287Q13 8.425 13 8t-.288-.712T12 7t-.712.288T11 8t.288.713T12 9t.713-.288M12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22\"/>"
},
"info-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M12.713 16.713Q13 16.425 13 16v-4q0-.425-.288-.712T12 11t-.712.288T11 12v4q0 .425.288.713T12 17t.713-.288m0-8Q13 8.425 13 8t-.288-.712T12 7t-.712.288T11 8t.288.713T12 9t.713-.288M12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4T6.325 6.325T4 12t2.325 5.675T12 20m0-8\"/>"
},
"info-rounded": {
"body": "<path fill=\"currentColor\" d=\"M12.713 16.713Q13 16.425 13 16v-4q0-.425-.288-.712T12 11t-.712.288T11 12v4q0 .425.288.713T12 17t.713-.288m0-8Q13 8.425 13 8t-.288-.712T12 7t-.712.288T11 8t.288.713T12 9t.713-.288M12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22\"/>"
},
"javascript-rounded": {
"body": "<path fill=\"currentColor\" d=\"M7.5 15q-.625 0-1.062-.437T6 13.5v-.25q0-.325.213-.537t.537-.213t.538.213t.212.537v.25H9V9.75q0-.325.213-.537T9.75 9t.538.213t.212.537v3.75q0 .625-.437 1.063T9 15zm5.5 0q-.425 0-.712-.288T12 14v-.5q0-.2.15-.35t.35-.15h.5q.2 0 .35.15t.15.35h2v-1H13q-.425 0-.712-.288T12 11.5V10q0-.425.288-.712T13 9h3q.425 0 .713.288T17 10v.5q0 .2-.15.35t-.35.15H16q-.2 0-.35-.15t-.15-.35h-2v1H16q.425 0 .713.288T17 12.5V14q0 .425-.288.713T16 15z\"/>"
},
"key": {
"body": "<path fill=\"currentColor\" d=\"M9.125 14.125Q10 13.25 10 12t-.875-2.125T7 9t-2.125.875T4 12t.875 2.125T7 15t2.125-.875M7 18q-2.5 0-4.25-1.75T1 12t1.75-4.25T7 6q2.025 0 3.538 1.15T12.65 10h8.375L23 11.975l-3.5 4L17 14l-2 2l-2-2h-.35q-.625 1.8-2.175 2.9T7 18\"/>"
},
"key-rounded": {
"body": "<path fill=\"currentColor\" d=\"M4.875 14.125Q4 13.25 4 12t.875-2.125T7 9t2.125.875T10 12t-.875 2.125T7 15t-2.125-.875M7 18q1.925 0 3.475-1.1T12.65 14H13l1.3 1.3q.15.15.325.212t.375.063t.375-.062t.325-.213L17 14l1.75 1.375q.15.125.338.187t.387.038t.363-.112t.287-.238l2.25-2.575q.125-.15.188-.325t.062-.375t-.075-.362t-.2-.288L21.325 10.3q-.15-.15-.337-.225T20.6 10h-7.95q-.6-1.7-2.113-2.85T7 6Q4.5 6 2.75 7.75T1 12t1.75 4.25T7 18\"/>"
},
"keyboard-arrow-down": {
"body": "<path fill=\"currentColor\" d=\"m12 15.4l-6-6L7.4 8l4.6 4.6L16.6 8L18 9.4z\"/>"
},
"keyboard-arrow-up": {
"body": "<path fill=\"currentColor\" d=\"m12 10.8l-4.6 4.6L6 14l6-6l6 6l-1.4 1.4z\"/>"
},
"keyboard-rounded": {
"body": "<path fill=\"currentColor\" d=\"M4 19q-.825 0-1.412-.587T2 17V7q0-.825.588-1.412T4 5h16q.825 0 1.413.588T22 7v10q0 .825-.587 1.413T20 19zm5-3h6q.425 0 .713-.288T16 15t-.288-.712T15 14H9q-.425 0-.712.288T8 15t.288.713T9 16M6.713 9.712Q7 9.425 7 9t-.288-.712T6 8t-.712.288T5 9t.288.713T6 10t.713-.288m3 0Q10 9.426 10 9t-.288-.712T9 8t-.712.288T8 9t.288.713T9 10t.713-.288m3 0Q13 9.426 13 9t-.288-.712T12 8t-.712.288T11 9t.288.713T12 10t.713-.288m3 0Q16 9.426 16 9t-.288-.712T15 8t-.712.288T14 9t.288.713T15 10t.713-.288m3 0Q19 9.426 19 9t-.288-.712T18 8t-.712.288T17 9t.288.713T18 10t.713-.288m-12 3Q7 12.426 7 12t-.288-.712T6 11t-.712.288T5 12t.288.713T6 13t.713-.288m3 0Q10 12.426 10 12t-.288-.712T9 11t-.712.288T8 12t.288.713T9 13t.713-.288m3 0Q13 12.426 13 12t-.288-.712T12 11t-.712.288T11 12t.288.713T12 13t.713-.288m3 0Q16 12.426 16 12t-.288-.712T15 11t-.712.288T14 12t.288.713T15 13t.713-.288m3 0Q19 12.426 19 12t-.288-.712T18 11t-.712.288T17 12t.288.713T18 13t.713-.288\"/>"
},
"language": {
"body": "<path fill=\"currentColor\" d=\"M8.125 21.213q-1.825-.788-3.187-2.15t-2.15-3.188T2 11.988t.788-3.875t2.15-3.175t3.187-2.15T12.013 2t3.875.788t3.175 2.15t2.15 3.175t.787 3.875t-.787 3.887t-2.15 3.188t-3.175 2.15t-3.875.787t-3.888-.787M12 19.95q.65-.9 1.125-1.875T13.9 16h-3.8q.3 1.1.775 2.075T12 19.95m-2.6-.4q-.45-.825-.787-1.713T8.05 16H5.1q.725 1.25 1.813 2.175T9.4 19.55m5.2 0q1.4-.45 2.488-1.375T18.9 16h-2.95q-.225.95-.562 1.838T14.6 19.55M4.25 14h3.4q-.075-.5-.112-.987T7.5 12t.038-1.012T7.65 10h-3.4q-.125.5-.187.988T4 12t.063 1.013t.187.987m5.4 0h4.7q.075-.5.113-.987T14.5 12t-.038-1.012T14.35 10h-4.7q-.075.5-.112.988T9.5 12t.038 1.013t.112.987m6.7 0h3.4q.125-.5.188-.987T20 12t-.062-1.012T19.75 10h-3.4q.075.5.113.988T16.5 12t-.038 1.013t-.112.987m-.4-6h2.95q-.725-1.25-1.812-2.175T14.6 4.45q.45.825.788 1.713T15.95 8M10.1 8h3.8q-.3-1.1-.775-2.075T12 4.05q-.65.9-1.125 1.875T10.1 8m-5 0h2.95q.225-.95.563-1.838T9.4 4.45Q8 4.9 6.912 5.825T5.1 8\"/>"
},
"layers-clear-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M18.975 7.425q.775.6.775 1.575t-.775 1.575L16.8 12.25q-.3.225-.675.2t-.65-.3q-.325-.325-.288-.763t.388-.712L17.75 9L12 4.55L10.35 5.8q-.3.225-.675.2t-.65-.3q-.325-.325-.288-.763t.388-.712l1.65-1.275q.55-.425 1.225-.425t1.225.425zM19.1 21.4l-3.3-3.3l-2.575 2q-.55.425-1.225.425t-1.225-.425l-6.75-5.25q-.4-.3-.387-.787t.412-.788q.275-.2.6-.2t.6.2L12 18.5l2.35-1.825l-1.75-1.7h.725l-.1.05q-.55.425-1.225.438t-1.225-.413l-5.75-4.475Q4.25 9.975 4.25 9t.775-1.575l.05-.05L2.1 4.425q-.3-.3-.312-.712T2.075 3t.713-.3t.712.3l17 17q.275.275.275.7t-.275.7t-.7.275t-.7-.275m.875-8.125q.375.3.375.775t-.375.8l-.3.25q-.3.25-.675.225t-.65-.3q-.325-.325-.3-.788t.4-.737l.3-.225q.275-.2.613-.2t.612.2\"/>"
},
"layers-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M4.025 14.85q-.4-.3-.387-.787t.412-.788q.275-.2.6-.2t.6.2L12 18.5l6.75-5.225q.275-.2.6-.2t.6.2q.4.3.413.787t-.388.788l-6.75 5.25q-.55.425-1.225.425t-1.225-.425zm6.75.2l-5.75-4.475Q4.25 9.975 4.25 9t.775-1.575l5.75-4.475q.55-.425 1.225-.425t1.225.425l5.75 4.475q.775.6.775 1.575t-.775 1.575l-5.75 4.475q-.55.425-1.225.425t-1.225-.425M12 13.45L17.75 9L12 4.55L6.25 9zM12 9\"/>"
},
"library-add-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M14.713 13.713Q15 13.425 15 13v-2h2q.425 0 .713-.288T18 10t-.288-.712T17 9h-2V7q0-.425-.288-.712T14 6t-.712.288T13 7v2h-2q-.425 0-.712.288T10 10t.288.713T11 11h2v2q0 .425.288.713T14 14t.713-.288M8 18q-.825 0-1.412-.587T6 16V4q0-.825.588-1.412T8 2h12q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18zm0-2h12V4H8zm-4 6q-.825 0-1.412-.587T2 20V7q0-.425.288-.712T3 6t.713.288T4 7v13h13q.425 0 .713.288T18 21t-.288.713T17 22zM8 4v12z\"/>"
},
"link": {
"body": "<path fill=\"currentColor\" d=\"M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z\"/>"
},
"link-rounded": {
"body": "<path fill=\"currentColor\" d=\"M7 17q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h3q.425 0 .713.288T11 8t-.288.713T10 9H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h3q.425 0 .713.288T11 16t-.288.713T10 17zm2-4q-.425 0-.712-.288T8 12t.288-.712T9 11h6q.425 0 .713.288T16 12t-.288.713T15 13zm5 4q-.425 0-.712-.288T13 16t.288-.712T14 15h3q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-3q-.425 0-.712-.288T13 8t.288-.712T14 7h3q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z\"/>"
},
"lock": {
"body": "<path fill=\"currentColor\" d=\"M6 22q-.825 0-1.412-.587T4 20V10q0-.825.588-1.412T6 8h1V6q0-2.075 1.463-3.537T12 1t3.538 1.463T17 6v2h1q.825 0 1.413.588T20 10v10q0 .825-.587 1.413T18 22zm7.413-5.587Q14 15.825 14 15t-.587-1.412T12 13t-1.412.588T10 15t.588 1.413T12 17t1.413-.587M9 8h6V6q0-1.25-.875-2.125T12 3t-2.125.875T9 6z\"/>"
},
"lock-open": {
"body": "<path fill=\"currentColor\" d=\"M6 8h9V6q0-1.25-.875-2.125T12 3t-2.125.875T9 6H7q0-2.075 1.463-3.537T12 1t3.538 1.463T17 6v2h1q.825 0 1.413.588T20 10v10q0 .825-.587 1.413T18 22H6q-.825 0-1.412-.587T4 20V10q0-.825.588-1.412T6 8m7.413 8.413Q14 15.825 14 15t-.587-1.412T12 13t-1.412.588T10 15t.588 1.413T12 17t1.413-.587\"/>"
},
"lock-open-right-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M6 20h12V10H6zm7.413-3.588Q14 15.826 14 15t-.587-1.412T12 13t-1.412.588T10 15t.588 1.413T12 17t1.413-.587M6 20V10zm0 2q-.825 0-1.412-.587T4 20V10q0-.825.588-1.412T6 8h7V6q0-2.075 1.463-3.537T18 1q1.775 0 3.1 1.075t1.75 2.7q.125.425-.162.825T22 6q-.425 0-.7-.175t-.4-.575q-.275-.95-1.062-1.6T18 3q-1.25 0-2.125.875T15 6v2h3q.825 0 1.413.588T20 10v10q0 .825-.587 1.413T18 22z\"/>"
},
"lock-outline": {
"body": "<path fill=\"currentColor\" d=\"M6 22q-.825 0-1.412-.587T4 20V10q0-.825.588-1.412T6 8h1V6q0-2.075 1.463-3.537T12 1t3.538 1.463T17 6v2h1q.825 0 1.413.588T20 10v10q0 .825-.587 1.413T18 22zm0-2h12V10H6zm7.413-3.588Q14 15.826 14 15t-.587-1.412T12 13t-1.412.588T10 15t.588 1.413T12 17t1.413-.587M9 8h6V6q0-1.25-.875-2.125T12 3t-2.125.875T9 6zM6 20V10z\"/>"
},
"login": {
"body": "<path fill=\"currentColor\" d=\"M12 21v-2h7V5h-7V3h7q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm-2-4l-1.375-1.45l2.55-2.55H3v-2h8.175l-2.55-2.55L10 7l5 5z\"/>"
},
"logout": {
"body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h7v2H5v14h7v2zm11-4l-1.375-1.45l2.55-2.55H9v-2h8.175l-2.55-2.55L16 7l5 5z\"/>"
},
"logout-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h6q.425 0 .713.288T12 4t-.288.713T11 5H5v14h6q.425 0 .713.288T12 20t-.288.713T11 21zm12.175-8H10q-.425 0-.712-.288T9 12t.288-.712T10 11h7.175L15.3 9.125q-.275-.275-.275-.675t.275-.7t.7-.313t.725.288L20.3 11.3q.3.3.3.7t-.3.7l-3.575 3.575q-.3.3-.712.288t-.713-.313q-.275-.3-.262-.712t.287-.688z\"/>"
},
"looks-one-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M12 9v7q0 .425.288.713T13 17t.713-.288T14 16V8q0-.425-.288-.712T13 7h-2q-.425 0-.712.288T10 8t.288.713T11 9zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5H5zM5 5v14z\"/>"
},
"mail-rounded": {
"body": "<path fill=\"currentColor\" d=\"M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm8.263-7.212q.137-.038.262-.113L19.6 8.25q.2-.125.3-.312t.1-.413q0-.5-.425-.75T18.7 6.8L12 11L5.3 6.8q-.45-.275-.875-.012T4 7.525q0 .25.1.438t.3.287l7.075 4.425q.125.075.263.113t.262.037t.263-.037\"/>"
},
"match-word-rounded": {
"body": "<path fill=\"currentColor\" d=\"M3 19.025q-.825 0-1.412-.587T1 17.025v-2q0-.425.288-.712T2 14.025t.713.288t.287.712v2h18v-2q0-.425.288-.713t.712-.287t.713.288t.287.712v2q0 .825-.587 1.413T21 19.025zm4.35-3.8q-1.225 0-1.925-.637t-.7-1.738q0-1.05.813-1.712t2.087-.663q.575 0 1.063.088t.837.287v-.35q0-.675-.462-1.075t-1.263-.4q-.375 0-.712.113t-.613.312q-.225.175-.487.2T5.5 9.5t-.275-.437t.15-.463q.45-.425 1.063-.65t1.387-.225q1.55 0 2.375.738t.825 2.137v3.675q0 .3-.213.513T10.3 15q-.325 0-.537-.213t-.213-.537v-.1h-.075q-.325.5-.875.788t-1.25.287m.55-3.575q-.8 0-1.225.313t-.425.887q0 .5.375.813t.975.312q.8 0 1.363-.562t.562-1.363q-.35-.2-.8-.3t-.825-.1m8.425 3.575q-1.025 0-1.562-.45t-.688-.7H14v.325q0 .3-.212.513t-.513.212t-.525-.225t-.225-.525V5.75q0-.325.225-.55t.55-.225t.55.225t.225.55V7.8L14 8.8h.075q.075-.125.6-.638t1.65-.512q1.6 0 2.525 1.15t.925 2.65t-.912 2.638t-2.538 1.137M16.1 9.05q-1 0-1.55.738T14 11.425q0 .925.55 1.65t1.55.725t1.563-.725t.562-1.65t-.562-1.65T16.1 9.05\"/>"
},
"menu-book-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M6.5 16q1.175 0 2.288.263T11 17.05V7.2q-1.025-.6-2.175-.9T6.5 6q-.9 0-1.788.175T3 6.7v9.9q.875-.3 1.738-.45T6.5 16m6.5 1.05q1.1-.525 2.213-.787T17.5 16q.9 0 1.763.15T21 16.6V6.7q-.825-.35-1.713-.525T17.5 6q-1.175 0-2.325.3T13 7.2zm-1.662 2.338q-.313-.088-.588-.238q-.975-.575-2.05-.862T6.5 18q-1.05 0-2.062.275T2.5 19.05q-.525.275-1.012-.025T1 18.15V6.1q0-.275.138-.525T1.55 5.2q1.15-.6 2.4-.9T6.5 4q1.45 0 2.838.375T12 5.5q1.275-.75 2.663-1.125T17.5 4q1.3 0 2.55.3t2.4.9q.275.125.413.375T23 6.1v12.05q0 .575-.487.875t-1.013.025q-.925-.5-1.937-.775T17.5 18q-1.125 0-2.2.288t-2.05.862q-.275.15-.587.238t-.663.087t-.663-.088M14 8.776q0-.225.163-.462T14.525 8q.725-.25 1.45-.375T17.5 7.5q.5 0 .988.063t.962.162q.225.05.388.25t.162.45q0 .425-.275.625t-.7.1q-.35-.075-.737-.112T17.5 9q-.65 0-1.275.125t-1.2.325q-.45.175-.737-.025T14 8.775m0 5.5q0-.225.163-.462t.362-.313q.725-.25 1.45-.375T17.5 13q.5 0 .988.063t.962.162q.225.05.388.25t.162.45q0 .425-.275.625t-.7.1q-.35-.075-.737-.112T17.5 14.5q-.65 0-1.275.113t-1.2.312q-.45.175-.737-.012T14 14.275m0-2.75q0-.225.163-.462t.362-.313q.725-.25 1.45-.375t1.525-.125q.5 0 .988.063t.962.162q.225.05.388.25t.162.45q0 .425-.275.625t-.7.1q-.35-.075-.737-.112t-.788-.038q-.65 0-1.275.125t-1.2.325q-.45.175-.737-.025t-.288-.65\"/>"
},
"menu-book-rounded": {
"body": "<path fill=\"currentColor\" d=\"M13 17.05q1.1-.525 2.213-.788T17.5 16q.9 0 1.763.15T21 16.6V6.7q-.825-.35-1.713-.525T17.5 6q-1.175 0-2.325.3T13 7.2zm-1.662 2.338q-.313-.088-.588-.238q-.975-.575-2.05-.862T6.5 18q-1.05 0-2.062.275T2.5 19.05q-.525.275-1.012-.025T1 18.15V6.1q0-.275.138-.525T1.55 5.2q1.175-.575 2.413-.888T6.5 4q1.45 0 2.838.375T12 5.5q1.275-.75 2.663-1.125T17.5 4q1.3 0 2.538.313t2.412.887q.275.125.413.375T23 6.1v12.05q0 .575-.487.875t-1.013.025q-.925-.5-1.937-.775T17.5 18q-1.125 0-2.2.288t-2.05.862q-.275.15-.587.238t-.663.087t-.663-.088M14 8.776q0-.225.163-.462T14.525 8q.725-.25 1.45-.375T17.5 7.5q.5 0 .988.063t.962.162q.225.05.388.25t.162.45q0 .425-.275.625t-.7.1q-.35-.075-.737-.112T17.5 9q-.65 0-1.275.125t-1.2.325q-.45.175-.737-.025T14 8.775m0 5.5q0-.225.163-.462t.362-.313q.725-.25 1.45-.375T17.5 13q.5 0 .988.063t.962.162q.225.05.388.25t.162.45q0 .425-.275.625t-.7.1q-.35-.075-.737-.112T17.5 14.5q-.65 0-1.275.113t-1.2.312q-.45.175-.737-.012T14 14.275m0-2.75q0-.225.163-.462t.362-.313q.725-.25 1.45-.375t1.525-.125q.5 0 .988.063t.962.162q.225.05.388.25t.162.45q0 .425-.275.625t-.7.1q-.35-.075-.737-.112t-.788-.038q-.65 0-1.275.125t-1.2.325q-.45.175-.737-.025t-.288-.65\"/>"
},
"more-vert": {
"body": "<path fill=\"currentColor\" d=\"M12 20q-.825 0-1.412-.587T10 18t.588-1.412T12 16t1.413.588T14 18t-.587 1.413T12 20m0-6q-.825 0-1.412-.587T10 12t.588-1.412T12 10t1.413.588T14 12t-.587 1.413T12 14m0-6q-.825 0-1.412-.587T10 6t.588-1.412T12 4t1.413.588T14 6t-.587 1.413T12 8\"/>"
},
"move-down-rounded": {
"body": "<path fill=\"currentColor\" d=\"M3 11q0 1.8 1.15 3.175T7.075 15.9l-.775-.775q-.275-.275-.275-.687t.275-.713q.3-.3.713-.3t.712.3L10.3 16.3q.3.3.3.7t-.3.7l-2.6 2.6q-.275.275-.7.275t-.725-.3Q6 20 6 19.6t.275-.7l.9-.95q-2.65-.3-4.412-2.287T1 11q0-2.925 2.038-4.962T8 4h2q.425 0 .713.288T11 5t-.288.713T10 6H8Q5.925 6 4.463 7.463T3 11m11 9q-.425 0-.712-.288T13 19v-5q0-.425.288-.712T14 13h7q.425 0 .713.288T22 14v5q0 .425-.288.713T21 20zm0-9q-.425 0-.712-.288T13 10V5q0-.425.288-.712T14 4h7q.425 0 .713.288T22 5v5q0 .425-.288.713T21 11zm1-2h5V6h-5z\"/>"
},
"opacity": {
"body": "<path fill=\"currentColor\" d=\"M6.338 18.7Q4 16.4 4 13.1q0-1.625.625-3.037T6.35 7.55L12 2l5.65 5.55q1.1 1.1 1.725 2.513T20 13.1q0 3.3-2.337 5.6T12 21t-5.663-2.3M6.05 14H17.9q.3-1.8-.337-3.075T16.25 9L12 4.8L7.75 9q-.675.65-1.325 1.925T6.05 14\"/>"
},
"open-in-new-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h6q.425 0 .713.288T12 4t-.288.713T11 5H5v14h14v-6q0-.425.288-.712T20 12t.713.288T21 13v6q0 .825-.587 1.413T19 21zM19 6.4L10.4 15q-.275.275-.7.275T9 15t-.275-.7t.275-.7L17.6 5H15q-.425 0-.712-.288T14 4t.288-.712T15 3h5q.425 0 .713.288T21 4v5q0 .425-.288.713T20 10t-.712-.288T19 9z\"/>"
},
"palette-outline": {
"body": "<path fill=\"currentColor\" d=\"M12 22q-2.05 0-3.875-.788t-3.187-2.15t-2.15-3.187T2 12q0-2.075.813-3.9t2.2-3.175T8.25 2.788T12.2 2q2 0 3.775.688t3.113 1.9t2.125 2.875T22 11.05q0 2.875-1.75 4.413T16 17h-1.85q-.225 0-.312.125t-.088.275q0 .3.375.863t.375 1.287q0 1.25-.687 1.85T12 22m-4.425-9.425Q8 12.15 8 11.5t-.425-1.075T6.5 10t-1.075.425T5 11.5t.425 1.075T6.5 13t1.075-.425m3-4Q11 8.15 11 7.5t-.425-1.075T9.5 6t-1.075.425T8 7.5t.425 1.075T9.5 9t1.075-.425m5 0Q16 8.15 16 7.5t-.425-1.075T14.5 6t-1.075.425T13 7.5t.425 1.075T14.5 9t1.075-.425m3 4Q19 12.15 19 11.5t-.425-1.075T17.5 10t-1.075.425T16 11.5t.425 1.075T17.5 13t1.075-.425M12 20q.225 0 .363-.125t.137-.325q0-.35-.375-.825T11.75 17.3q0-1.05.725-1.675T14.25 15H16q1.65 0 2.825-.962T20 11.05q0-3.025-2.312-5.038T12.2 4Q8.8 4 6.4 6.325T4 12q0 3.325 2.338 5.663T12 20\"/>"
},
"pan-tool-rounded": {
"body": "<path fill=\"currentColor\" d=\"M10.05 23q-.75 0-1.4-.337T7.575 21.7l-5.9-8.65q-.2-.3-.175-.65t.3-.6q.475-.475 1.125-.55t1.175.3L7 13.575V4q0-.425.288-.712T8 3t.713.288T9 4v8h2V2q0-.425.288-.712T12 1t.713.288T13 2v10h2V3q0-.425.288-.712T16 2t.713.288T17 3v9h2V5q0-.425.288-.712T20 4t.713.288T21 5v14q0 1.65-1.175 2.825T17 23z\"/>"
},
"password-rounded": {
"body": "<path fill=\"currentColor\" d=\"M3 17h18q.425 0 .713.288T22 18t-.288.713T21 19H3q-.425 0-.712-.288T2 18t.288-.712T3 17m1-5.55l-.475.85q-.15.275-.45.35t-.575-.075t-.35-.45t.075-.575l.475-.85h-.95q-.325 0-.537-.212T1 9.95t.213-.537t.537-.213h.95l-.475-.8q-.15-.275-.075-.575t.35-.45t.575-.075t.45.35l.475.8l.475-.8q.15-.275.45-.35t.575.075t.35.45t-.075.575l-.475.8h.95q.325 0 .538.213T7 9.95t-.213.538t-.537.212H5.3l.475.85q.15.275.075.575t-.35.45t-.575.075t-.45-.35zm8 0l-.475.85q-.15.275-.45.35t-.575-.075t-.35-.45t.075-.575l.475-.85h-.95q-.325 0-.537-.212T9 9.95t.213-.537t.537-.213h.95l-.475-.8q-.15-.275-.075-.575t.35-.45t.575-.075t.45.35l.475.8l.475-.8q.15-.275.45-.35t.575.075t.35.45t-.075.575l-.475.8h.95q.325 0 .537.213T15 9.95t-.213.538t-.537.212h-.95l.475.85q.15.275.075.575t-.35.45t-.575.075t-.45-.35zm8 0l-.475.85q-.15.275-.45.35t-.575-.075t-.35-.45t.075-.575l.475-.85h-.95q-.325 0-.537-.212T17 9.95t.213-.537t.537-.213h.95l-.475-.8q-.15-.275-.075-.575t.35-.45t.575-.075t.45.35l.475.8l.475-.8q.15-.275.45-.35t.575.075t.35.45t-.075.575l-.475.8h.95q.325 0 .538.213T23 9.95t-.213.538t-.537.212h-.95l.475.85q.15.275.075.575t-.35.45t-.575.075t-.45-.35z\"/>"
},
"pause-rounded": {
"body": "<path fill=\"currentColor\" d=\"M16 19q-.825 0-1.412-.587T14 17V7q0-.825.588-1.412T16 5t1.413.588T18 7v10q0 .825-.587 1.413T16 19m-8 0q-.825 0-1.412-.587T6 17V7q0-.825.588-1.412T8 5t1.413.588T10 7v10q0 .825-.587 1.413T8 19\"/>"
},
"person": {
"body": "<path fill=\"currentColor\" d=\"M9.175 10.825Q8 9.65 8 8t1.175-2.825T12 4t2.825 1.175T16 8t-1.175 2.825T12 12t-2.825-1.175M4 20v-2.8q0-.85.438-1.562T5.6 14.55q1.55-.775 3.15-1.162T12 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T20 17.2V20z\"/>"
},
"person-add": {
"body": "<path fill=\"currentColor\" d=\"M18 14v-3h-3V9h3V6h2v3h3v2h-3v3zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175M1 20v-2.8q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2V20z\"/>"
},
"person-add-rounded": {
"body": "<path fill=\"currentColor\" d=\"M18 11h-2q-.425 0-.712-.288T15 10t.288-.712T16 9h2V7q0-.425.288-.712T19 6t.713.288T20 7v2h2q.425 0 .713.288T23 10t-.288.713T22 11h-2v2q0 .425-.288.713T19 14t-.712-.288T18 13zm-11.825-.175Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175M1 18v-.8q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2v.8q0 .825-.587 1.413T15 20H3q-.825 0-1.412-.587T1 18\"/>"
},
"person-check": {
"body": "<path fill=\"currentColor\" d=\"M17.55 12L14 8.45l1.425-1.4l2.125 2.125l4.25-4.25l1.4 1.425zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175M1 20v-2.8q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2V20z\"/>"
},
"person-off": {
"body": "<path fill=\"currentColor\" d=\"M19.775 22.625L17.15 20H4v-2.8q0-.85.438-1.562T5.6 14.55q1.125-.575 2.288-.925t2.362-.525L1.375 4.225L2.8 2.8l18.4 18.4zM18.4 14.55q.725.35 1.15 1.062T20 17.15l-3.35-3.35q.45.175.888.35t.862.4m-4.2-3.2L8.65 5.8q.575-.85 1.45-1.325T12 4q1.65 0 2.825 1.175T16 8q0 1.025-.475 1.9T14.2 11.35\"/>"
},
"person-remove": {
"body": "<path fill=\"currentColor\" d=\"M16 11V9h6v2zm-9.825-.175Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175M1 20v-2.8q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2V20z\"/>"
},
"person-rounded": {
"body": "<path fill=\"currentColor\" d=\"M9.175 10.825Q8 9.65 8 8t1.175-2.825T12 4t2.825 1.175T16 8t-1.175 2.825T12 12t-2.825-1.175M4 18v-.8q0-.85.438-1.562T5.6 14.55q1.55-.775 3.15-1.162T12 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T20 17.2v.8q0 .825-.587 1.413T18 20H6q-.825 0-1.412-.587T4 18\"/>"
},
"photo-library-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M13.25 12.5L12.1 11q-.15-.175-.4-.175t-.4.175l-1.675 2.2q-.2.25-.063.525t.463.275h7.95q.325 0 .462-.275t-.062-.525l-2.425-3.175q-.15-.2-.4-.2t-.4.2l-1.9 2.475ZM8 18q-.825 0-1.413-.588T6 16V4q0-.825.588-1.413T8 2h12q.825 0 1.413.588T22 4v12q0 .825-.588 1.413T20 18H8Zm0-2h12V4H8v12Zm-4 6q-.825 0-1.413-.588T2 20V7q0-.425.288-.713T3 6q.425 0 .713.288T4 7v13h13q.425 0 .713.288T18 21q0 .425-.288.713T17 22H4ZM8 4v12V4Z\"/>",
"hidden": true
},
"picture-as-pdf-rounded": {
"body": "<path fill=\"currentColor\" d=\"M10 10.5h1q.425 0 .713-.288T12 9.5v-1q0-.425-.288-.712T11 7.5H9.5q-.2 0-.35.15T9 8v4q0 .2.15.35t.35.15t.35-.15T10 12zm0-1v-1h1v1zm5 3q.425 0 .713-.288T16 11.5v-3q0-.425-.288-.712T15 7.5h-1.5q-.2 0-.35.15T13 8v4q0 .2.15.35t.35.15zm-1-1v-3h1v3zm4-1h.5q.2 0 .35-.15T19 10t-.15-.35t-.35-.15H18v-1h.5q.2 0 .35-.15T19 8t-.15-.35t-.35-.15h-1q-.2 0-.35.15T17 8v4q0 .2.15.35t.35.15t.35-.15T18 12zM8 18q-.825 0-1.412-.587T6 16V4q0-.825.588-1.412T8 2h12q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18zm-4 4q-.825 0-1.412-.587T2 20V7q0-.425.288-.712T3 6t.713.288T4 7v13h13q.425 0 .713.288T18 21t-.288.713T17 22z\"/>"
},
"pin-end": {
"body": "<path fill=\"currentColor\" d=\"M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v6h-2V6H4v12h10v2zm9.95-5.625L11 11.425v2.225H9V8h5.65v2H12.4l2.95 2.95zM19 20q-1.25 0-2.125-.875T16 17t.875-2.125T19 14t2.125.875T22 17t-.875 2.125T19 20\"/>"
},
"play-arrow-rounded": {
"body": "<path fill=\"currentColor\" d=\"M8 17.175V6.825q0-.425.3-.713t.7-.287q.125 0 .263.037t.262.113l8.15 5.175q.225.15.338.375t.112.475t-.112.475t-.338.375l-8.15 5.175q-.125.075-.262.113T9 18.175q-.4 0-.7-.288t-.3-.712\"/>"
},
"play-circle": {
"body": "<path fill=\"currentColor\" d=\"m9.5 16.5l7-4.5l-7-4.5zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22\"/>"
},
"preview-off-rounded": {
"body": "<path fill=\"currentColor\" d=\"M12 17q-2.05 0-3.662-1.112T6 13q.5-1.2 1.4-2.1t2.1-1.4l1.175 1.175q-.975.275-1.75.863T7.65 13q.65 1.175 1.8 1.838T12 15.5q.75 0 1.45-.2t1.275-.575L15.8 15.8q-.8.575-1.763.888T12 17m5.225-2.6l-1.075-1.075q.05-.075.1-.162t.1-.163q-.45-.825-1.175-1.412t-1.625-.863L11.825 9q2.05 0 3.75 1.113T18 13q-.15.375-.337.725t-.438.675M5 21q-.825 0-1.412-.587T3 19V5.825L2.075 4.9Q1.8 4.625 1.8 4.212t.3-.712q.275-.275.7-.275t.7.275l17 17q.3.3.288.7t-.313.7q-.3.275-.7.288t-.7-.288l-.9-.9zm0-2h11.175L5 7.825zm16-.825l-2-2V8h-8.175l-5-5H19q.825 0 1.413.588T21 5z\"/>"
},
"qr-code-rounded": {
"body": "<path fill=\"currentColor\" d=\"M3 10V4q0-.425.288-.712T4 3h6q.425 0 .713.288T11 4v6q0 .425-.288.713T10 11H4q-.425 0-.712-.288T3 10m2-1h4V5H5zM3 20v-6q0-.425.288-.712T4 13h6q.425 0 .713.288T11 14v6q0 .425-.288.713T10 21H4q-.425 0-.712-.288T3 20m2-1h4v-4H5zm8-9V4q0-.425.288-.712T14 3h6q.425 0 .713.288T21 4v6q0 .425-.288.713T20 11h-6q-.425 0-.712-.288T13 10m2-1h4V5h-4zm4 12v-2h2v2zm-6-6v-2h2v2zm2 2v-2h2v2zm-2 2v-2h2v2zm2 2v-2h2v2zm2-2v-2h2v2zm0-4v-2h2v2zm2 2v-2h2v2z\"/>"
},
"quick-reference-all-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5 4v6.025V10v10zv5zm3 10h2q.425 0 .713-.288T11 13t-.288-.712T10 12H8q-.425 0-.712.288T7 13t.288.713T8 14m0 4h1q.425 0 .713-.288T10 17t-.288-.712T9 16H8q-.425 0-.712.288T7 17t.288.713T8 18m-3 4q-.825 0-1.412-.587T3 20V4q0-.825.588-1.412T5 2h7.175q.4 0 .763.15t.637.425l4.85 4.85q.275.275.425.638t.15.762v.7q0 .425-.288.7T18 10.5t-.712-.288T17 9.5V9h-4q-.425 0-.712-.288T12 8V4H5v16h5.5q.425 0 .713.288T11.5 21t-.288.713T10.5 22zm13.275-3.725Q19 17.55 19 16.5t-.725-1.775T16.5 14t-1.775.725T14 16.5t.725 1.775T16.5 19t1.775-.725m3.325 4.3q-.425 0-.7-.275l-2-2q-.525.35-1.137.525T16.5 21q-1.875 0-3.187-1.312T12 16.5t1.313-3.187T16.5 12t3.188 1.313T21 16.5q0 .65-.175 1.263T20.3 18.9l2 2q.275.275.275.7t-.275.7t-.7.275\"/>"
},
"radio-button-unchecked": {
"body": "<path fill=\"currentColor\" d=\"M12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4T6.325 6.325T4 12t2.325 5.675T12 20\"/>"
},
"redo": {
"body": "<path fill=\"currentColor\" d=\"M9.9 19q-2.425 0-4.163-1.575T4 13.5t1.738-3.925T9.9 8h6.3l-2.6-2.6L15 4l5 5l-5 5l-1.4-1.4l2.6-2.6H9.9q-1.575 0-2.738 1T6 13.5T7.163 16T9.9 17H17v2z\"/>"
},
"refresh": {
"body": "<path fill=\"currentColor\" d=\"M12 20q-3.35 0-5.675-2.325T4 12t2.325-5.675T12 4q1.725 0 3.3.712T18 6.75V4h2v7h-7V9h4.2q-.8-1.4-2.187-2.2T12 6Q9.5 6 7.75 7.75T6 12t1.75 4.25T12 18q1.925 0 3.475-1.1T17.65 14h2.1q-.7 2.65-2.85 4.325T12 20\"/>"
},
"refresh-rounded": {
"body": "<path fill=\"currentColor\" d=\"M12 20q-3.35 0-5.675-2.325T4 12t2.325-5.675T12 4q1.725 0 3.3.712T18 6.75V5q0-.425.288-.712T19 4t.713.288T20 5v5q0 .425-.288.713T19 11h-5q-.425 0-.712-.288T13 10t.288-.712T14 9h3.2q-.8-1.4-2.187-2.2T12 6Q9.5 6 7.75 7.75T6 12t1.75 4.25T12 18q1.7 0 3.113-.862t2.187-2.313q.2-.35.563-.487t.737-.013q.4.125.575.525t-.025.75q-1.025 2-2.925 3.2T12 20\"/>"
},
"remove-moderator-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M20 6.375V11.1q0 .875-.137 1.738t-.413 1.712q-.175.525-.55.688t-.725.037t-.587-.437t-.088-.763q.25-.7.375-1.462T18 11.1V6.375l-6-2.25l-3.05 1.15q-.275.1-.562.038T7.9 5.05q-.4-.4-.275-.925T8.25 3.4l3.05-1.125q.35-.125.7-.125t.7.125l6 2.25q.575.225.938.725T20 6.375M12 21.9q-.1 0-.625-.1Q8 20.675 6 17.637T4 11.1V6.8L2.1 4.9q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275l17 17q.275.275.275.7t-.275.7t-.7.275t-.7-.275l-2.55-2.55q-.825.875-1.812 1.475t-2.113.975q-.15.05-.3.075T12 21.9m0-2q.875-.275 1.675-.775t1.475-1.175L6 8.8v2.3q0 3.025 1.7 5.5t4.3 3.3\"/>"
},
"remove-selection-rounded": {
"body": "<path fill=\"currentColor\" d=\"m16.075 17.475l-3.25 3.25q-.275.275-.7.275t-.7-.275t-.275-.7t.275-.7l3.25-3.25l-3.25-3.25q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275l3.25 3.25l3.25-3.25q.275-.275.7-.275t.7.275t.275.7t-.275.7l-3.25 3.25l3.25 3.25q.275.275.275.7t-.275.7t-.7.275t-.7-.275zM3.288 4.712Q3 4.426 3 4t.288-.712T4 3t.713.288T5 4t-.288.713T4 5t-.712-.288m4 0Q7 4.426 7 4t.288-.712T8 3t.713.288T9 4t-.288.713T8 5t-.712-.288m4 0Q11 4.426 11 4t.288-.712T12 3t.713.288T13 4t-.288.713T12 5t-.712-.288m4 0Q15 4.426 15 4t.288-.712T16 3t.713.288T17 4t-.288.713T16 5t-.712-.288m4 0Q19 4.426 19 4t.288-.712T20 3t.713.288T21 4t-.288.713T20 5t-.712-.288m-12 16Q7 20.426 7 20t.288-.712T8 19t.713.288T9 20t-.288.713T8 21t-.712-.288m-4-12Q3 8.425 3 8t.288-.712T4 7t.713.288T5 8t-.288.713T4 9t-.712-.288m0 4Q3 12.426 3 12t.288-.712T4 11t.713.288T5 12t-.288.713T4 13t-.712-.288m0 4Q3 16.426 3 16t.288-.712T4 15t.713.288T5 16t-.288.713T4 17t-.712-.288m0 4Q3 20.426 3 20t.288-.712T4 19t.713.288T5 20t-.288.713T4 21t-.712-.288m16-12Q19 8.425 19 8t.288-.712T20 7t.713.288T21 8t-.288.713T20 9t-.712-.288\"/>"
},
"rocket-launch": {
"body": "<path fill=\"currentColor\" d=\"m2.45 10.575l4.2-4.2q.35-.35.825-.5t.975-.05l1.3.275Q8.4 7.7 7.625 9t-1.5 3.15zm5.125 2.275q.575-1.8 1.563-3.4t2.387-3q2.2-2.2 5.025-3.287t5.275-.663q.425 2.45-.65 5.275T17.9 12.8q-1.375 1.375-3 2.388t-3.425 1.587zm8.313-2.425q.837 0 1.412-.575t.575-1.412t-.575-1.413t-1.412-.575t-1.413.575t-.575 1.413t.575 1.412t1.413.575m-2.113 11.45l-1.6-3.675q1.85-.725 3.163-1.5t2.912-2.125l.25 1.3q.1.5-.05.988t-.5.837zM4.05 16.05q.875-.875 2.125-.888t2.125.863t.875 2.125t-.875 2.125q-.625.625-2.088 1.075t-4.037.8q.35-2.575.8-4.025T4.05 16.05\"/>"
},
"rotate-left": {
"body": "<path fill=\"currentColor\" d=\"M11 22q-1.25-.125-2.4-.612T6.4 20.1l1.4-1.45q.725.525 1.538.85t1.662.45zm2 0v-2.05q2.6-.375 4.3-2.337T19 13.05q0-2.925-2.037-4.962T12 6.05h-.2l1.6 1.6l-1.4 1.4l-4-4l4-4l1.4 1.45l-1.55 1.55H12q1.875 0 3.513.713t2.85 1.925t1.925 2.85T21 13.05q0 3.425-2.275 5.963T13 22m-8.05-3.35q-.8-1.05-1.288-2.2t-.612-2.4H5.1q.125.85.45 1.663t.85 1.537zm-1.9-6.6q.15-1.275.625-2.45T4.95 7.45l1.45 1.4q-.525.725-.85 1.538T5.1 12.05z\"/>"
},
"rotate-right": {
"body": "<path fill=\"currentColor\" d=\"M13.05 22v-2.05q.85-.125 1.663-.45t1.537-.85l1.4 1.45q-1.05.8-2.2 1.287t-2.4.613m-2 0q-3.45-.45-5.725-2.988T3.05 13.05q0-1.875.713-3.512t1.925-2.85t2.85-1.925t3.512-.713h.15L10.65 2.5l1.4-1.45l4 4l-4 4l-1.4-1.4l1.6-1.6h-.2q-2.925 0-4.962 2.038T5.05 13.05q0 2.6 1.7 4.563t4.3 2.337zm8.05-3.35l-1.45-1.4q.525-.725.85-1.537t.45-1.663H21q-.125 1.25-.612 2.4t-1.288 2.2m1.9-6.6h-2.05q-.125-.85-.45-1.662t-.85-1.538l1.45-1.4q.8.975 1.275 2.15T21 12.05\"/>"
},
"rotate-right-rounded": {
"body": "<path fill=\"currentColor\" d=\"M19.75 12.05q-.3 0-.587-.213T18.8 11.3q-.125-.475-.312-.937t-.438-.888q-.175-.275-.125-.612T18.2 8.3q.375-.35.863-.3t.737.475q.325.55.575 1.125t.4 1.2q.125.5-.187.875t-.838.375m-6.7 8.7q0-.3.213-.587t.537-.363q.475-.125.938-.312t.887-.438q.275-.175.613-.125t.562.275q.35.375.3.863t-.475.737q-.575.325-1.15.563t-1.175.412q-.5.125-.875-.175t-.375-.85m5.15-2.95q-.225-.2-.275-.55t.125-.625q.25-.425.438-.888t.312-.937q.075-.325.35-.537t.6-.213q.55 0 .85.375t.175.875q-.175.6-.412 1.175t-.563 1.15q-.25.425-.737.475t-.863-.3m-8.375 3.95q-2.975-.8-4.875-3.2t-1.9-5.5q0-3.75 2.625-6.375T12.05 4.05h.15l-.875-.875q-.275-.275-.275-.7t.3-.725q.275-.275.7-.275t.725.275l2.575 2.6q.15.15.213.325t.062.375t-.062.375t-.213.325l-2.6 2.6q-.275.275-.7.275t-.7-.275t-.275-.7t.275-.7l.9-.9h-.2q-2.925 0-4.962 2.037T5.05 13.05q0 2.425 1.475 4.288T10.3 19.8q.325.075.538.35t.212.6q0 .525-.363.825t-.862.175\"/>"
},
"save": {
"body": "<path fill=\"currentColor\" d=\"M21 7v12q0 .825-.587 1.413T19 21H5q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h12zm-6.875 10.125Q15 16.25 15 15t-.875-2.125T12 12t-2.125.875T9 15t.875 2.125T12 18t2.125-.875M6 10h9V6H6z\"/>"
},
"save-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h11.175q.4 0 .763.15t.637.425l2.85 2.85q.275.275.425.638t.15.762V19q0 .825-.587 1.413T19 21zm9.125-3.875Q15 16.25 15 15t-.875-2.125T12 12t-2.125.875T9 15t.875 2.125T12 18t2.125-.875M7 10h7q.425 0 .713-.288T15 9V7q0-.425-.288-.712T14 6H7q-.425 0-.712.288T6 7v2q0 .425.288.713T7 10\"/>"
},
"scan-delete-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M6 4v5zv16v-.238V20zm0 18q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h7.175q.4 0 .763.15t.637.425l4.85 4.85q.275.275.425.638t.15.762V11.6q0 .425-.288.713T19 12.6t-.712-.287T18 11.6V9h-3q-.825 0-1.412-.587T13 7V4H6v16h6.2q.425 0 .713.288T13.2 21t-.287.713T12.2 22zm12-2.575L16.6 20.8q-.275.275-.687.288T15.2 20.8q-.275-.275-.275-.7t.275-.7l1.4-1.4l-1.4-1.4q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275l1.4 1.4l1.4-1.4q.275-.275.688-.287t.712.287q.275.275.275.7t-.275.7L19.425 18l1.375 1.4q.275.275.288.688t-.288.712q-.275.275-.7.275t-.7-.275z\"/>"
},
"scan-delete-rounded": {
"body": "<path fill=\"currentColor\" d=\"M15 9h3l-5-5v3q0 .825.588 1.413T15 9m3 10.425L16.6 20.8q-.275.275-.687.288T15.2 20.8q-.275-.275-.275-.7t.275-.7l1.4-1.4l-1.4-1.4q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275l1.4 1.4l1.4-1.4q.275-.275.688-.287t.712.287q.275.275.275.7t-.275.7L19.425 18l1.375 1.4q.275.275.288.688t-.288.712q-.275.275-.7.275t-.7-.275zM6 22q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h7.175q.4 0 .763.15t.637.425l4.85 4.85q.275.275.425.638t.15.762v2.725q0 .3-.238.475t-.537.1q-1.475-.325-2.925.1t-2.55 1.525q-.8.8-1.275 1.887T12 17.976q0 .725.175 1.45T12.7 20.8q.225.425.025.813T12.1 22z\"/>"
},
"scanner-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M17.6 12L4.45 7.25q-.4-.15-.575-.525T3.85 5.95t.525-.575t.775-.025L19.8 10.7q.5.2.85.7t.35 1.1V18q0 .825-.587 1.413T19 20H5q-.825 0-1.412-.587T3 18v-4q0-.825.588-1.412T5 12zm1.4 6v-4H5v4zm-8-1h6q.425 0 .713-.288T18 16t-.288-.712T17 15h-6q-.425 0-.712.288T10 16t.288.713T11 17m-3.287-.288Q8 16.426 8 16t-.288-.712T7 15t-.712.288T6 16t.288.713T7 17t.713-.288M5 18v-4z\"/>"
},
"schedule-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M13 11.6V8q0-.425-.288-.712T12 7t-.712.288T11 8v3.975q0 .2.075.388t.225.337l3.3 3.3q.275.275.7.275T16 16t.275-.7t-.275-.7zM12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22m0-2q3.325 0 5.663-2.337T20 12t-2.337-5.663T12 4T6.337 6.338T4 12t2.338 5.663T12 20\"/>"
},
"search": {
"body": "<path fill=\"currentColor\" d=\"m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14\"/>"
},
"search-rounded": {
"body": "<path fill=\"currentColor\" d=\"M9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l5.6 5.6q.275.275.275.7t-.275.7t-.7.275t-.7-.275l-5.6-5.6q-.75.6-1.725.95T9.5 16m0-2q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14\"/>"
},
"security": {
"body": "<path fill=\"currentColor\" d=\"M12 22q-3.475-.875-5.738-3.988T4 11.1V5l8-3l8 3v6.1q0 3.8-2.262 6.913T12 22m0-2.1q2.425-.75 4.05-2.963T17.95 12H12V4.125l-6 2.25v5.175q0 .175.05.45H12z\"/>"
},
"select-all": {
"body": "<path fill=\"currentColor\" d=\"M7 17V7h10v10zm2-2h6V9H9zm-4 4v2q-.825 0-1.412-.587T3 19zm-2-2v-2h2v2zm0-4v-2h2v2zm0-4V7h2v2zm2-4H3q0-.825.588-1.412T5 3zm2 16v-2h2v2zM7 5V3h2v2zm4 16v-2h2v2zm0-16V3h2v2zm4 16v-2h2v2zm0-16V3h2v2zm4 16v-2h2q0 .825-.587 1.413T19 21m0-4v-2h2v2zm0-4v-2h2v2zm0-4V7h2v2zm0-4V3q.825 0 1.413.588T21 5z\"/>"
},
"send-rounded": {
"body": "<path fill=\"currentColor\" d=\"M4.4 19.425q-.5.2-.95-.088T3 18.5V14l8-2l-8-2V5.5q0-.55.45-.837t.95-.088l15.4 6.5q.625.275.625.925t-.625.925z\"/>"
},
"settings-rounded": {
"body": "<path fill=\"currentColor\" d=\"M10.825 22q-.675 0-1.162-.45t-.588-1.1L8.85 18.8q-.325-.125-.612-.3t-.563-.375l-1.55.65q-.625.275-1.25.05t-.975-.8l-1.175-2.05q-.35-.575-.2-1.225t.675-1.075l1.325-1Q4.5 12.5 4.5 12.337v-.675q0-.162.025-.337l-1.325-1Q2.675 9.9 2.525 9.25t.2-1.225L3.9 5.975q.35-.575.975-.8t1.25.05l1.55.65q.275-.2.575-.375t.6-.3l.225-1.65q.1-.65.588-1.1T10.825 2h2.35q.675 0 1.163.45t.587 1.1l.225 1.65q.325.125.613.3t.562.375l1.55-.65q.625-.275 1.25-.05t.975.8l1.175 2.05q.35.575.2 1.225t-.675 1.075l-1.325 1q.025.175.025.338v.674q0 .163-.05.338l1.325 1q.525.425.675 1.075t-.2 1.225l-1.2 2.05q-.35.575-.975.8t-1.25-.05l-1.5-.65q-.275.2-.575.375t-.6.3l-.225 1.65q-.1.65-.587 1.1t-1.163.45zm1.225-6.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5\"/>"
},
"shield-lock": {
"body": "<path fill=\"currentColor\" d=\"M12 22q-3.475-.875-5.738-3.988T4 11.1V5l8-3l8 3v6.1q0 3.8-2.262 6.913T12 22m-2-6h4q.425 0 .713-.288T15 15v-3q0-.425-.288-.712T14 11v-1q0-.825-.587-1.412T12 8t-1.412.588T10 10v1q-.425 0-.712.288T9 12v3q0 .425.288.713T10 16m1-5v-1q0-.425.288-.712T12 9t.713.288T13 10v1z\"/>"
},
"shield-rounded": {
"body": "<path fill=\"currentColor\" d=\"M11.675 21.875q-.15-.025-.3-.075Q8 20.675 6 17.637T4 11.1V6.375q0-.625.363-1.125t.937-.725l6-2.25q.35-.125.7-.125t.7.125l6 2.25q.575.225.938.725T20 6.375V11.1q0 3.5-2 6.538T12.625 21.8q-.15.05-.3.075T12 21.9t-.325-.025\"/>"
},
"show-chart": {
"body": "<path fill=\"currentColor\" d=\"M3.5 18.5L2 17l7.5-7.5l4 4l7.1-8L22 6.9l-8.5 9.6l-4-4z\"/>"
},
"signature-rounded": {
"body": "<path fill=\"currentColor\" d=\"M14.075 11.725q1.825-1.35 2.85-2.962T17.95 5.55q0-.8-.262-1.175T16.975 4Q15.8 4 14.9 5.988t-.9 4.487q0 .35.013.663t.062.587M4.1 16.3q-.275.275-.7.275t-.7-.275t-.275-.7t.275-.7l.9-.9l-.9-.9q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275l.9.9l.9-.9q.275-.275.7-.275t.7.275t.275.7t-.275.7l-.9.9l.9.9q.275.275.275.7t-.275.7t-.7.275t-.7-.275l-.9-.9zm11.35-.3q-.75 0-1.375-.288T13 14.776q-.4.2-.825.4l-.85.4q-.4.175-.787.013T10 15.025t.038-.775t.587-.55l.825-.387q.4-.188.775-.388q-.125-.55-.187-1.2t-.063-1.4q0-3.6 1.425-5.962T16.975 2q1.3 0 2.125.963t.825 2.687q0 2.15-1.362 4.25t-3.788 3.775q.175.175.363.263t.387.087q.525 0 1.225-.575t1.35-1.55q.25-.35.638-.488t.762.038q.375.2.55.588t.1.812q-.05.3-.05.575t.075.525q.125-.05.288-.162t.337-.288q.3-.275.713-.312t.737.212q.35.275.375.675t-.25.675q-.575.575-1.213.913T19.95 16q-.525 0-.937-.312t-.688-.963q-.7.625-1.425.95T15.45 16M3.288 20.713Q3 20.425 3 20t.288-.712T4 19t.713.288T5 20t-.288.713T4 21t-.712-.288m4 0Q7 20.426 7 20t.288-.712T8 19t.713.288T9 20t-.288.713T8 21t-.712-.288m4 0Q11 20.426 11 20t.288-.712T12 19t.713.288T13 20t-.288.713T12 21t-.712-.288m4 0Q15 20.426 15 20t.288-.712T16 19t.713.288T17 20t-.288.713T16 21t-.712-.288m4 0Q19 20.426 19 20t.288-.712T20 19t.713.288T21 20t-.288.713T20 21t-.712-.288\"/>"
},
"speed": {
"body": "<path fill=\"currentColor\" d=\"M10.45 15.5q.625.625 1.575.588T13.4 15.4L19 7l-8.4 5.6q-.65.45-.712 1.362t.562 1.538M5.1 20q-.55 0-1.012-.238t-.738-.712q-.65-1.175-1-2.437T2 14q0-2.075.788-3.9t2.137-3.175T8.1 4.788T12 4q2.05 0 3.85.775T19 6.888t2.15 3.125t.825 3.837q.025 1.375-.312 2.688t-1.038 2.512q-.275.475-.737.713T18.874 20z\"/>"
},
"star": {
"body": "<path fill=\"currentColor\" d=\"m5.825 21l1.625-7.025L2 9.25l7.2-.625L12 2l2.8 6.625l7.2.625l-5.45 4.725L18.175 21L12 17.275z\"/>"
},
"star-rounded": {
"body": "<path fill=\"currentColor\" d=\"m12 17.275l-4.15 2.5q-.275.175-.575.15t-.525-.2t-.35-.437t-.05-.588l1.1-4.725L3.775 10.8q-.25-.225-.312-.513t.037-.562t.3-.45t.55-.225l4.85-.425l1.875-4.45q.125-.3.388-.45t.537-.15t.537.15t.388.45l1.875 4.45l4.85.425q.35.05.55.225t.3.45t.038.563t-.313.512l-3.675 3.175l1.1 4.725q.075.325-.05.588t-.35.437t-.525.2t-.575-.15z\"/>"
},
"sticky-note-2": {
"body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v10l-6 6zm9-2l5-5h-5zm-7-5h5v-2H7zm0-4h10V8H7z\"/>"
},
"storage-rounded": {
"body": "<path fill=\"currentColor\" d=\"M5 20q-.825 0-1.412-.587T3 18t.588-1.412T5 16h14q.825 0 1.413.588T21 18t-.587 1.413T19 20zM5 8q-.825 0-1.412-.587T3 6t.588-1.412T5 4h14q.825 0 1.413.588T21 6t-.587 1.413T19 8zm0 6q-.825 0-1.412-.587T3 12t.588-1.412T5 10h14q.825 0 1.413.588T21 12t-.587 1.413T19 14zm1.713-7.288Q7 6.426 7 6t-.288-.712T6 5t-.712.288T5 6t.288.713T6 7t.713-.288m0 6Q7 12.426 7 12t-.288-.712T6 11t-.712.288T5 12t.288.713T6 13t.713-.288m0 6Q7 18.426 7 18t-.288-.712T6 17t-.712.288T5 18t.288.713T6 19t.713-.288\"/>"
},
"strikethrough-s": {
"body": "<path fill=\"currentColor\" d=\"M12.15 20q-1.9 0-3.375-1.125T6.65 15.8l2.2-.95q.35 1.2 1.213 1.975t2.137.775q1.05 0 1.9-.5t.85-1.6q0-.45-.175-.825T14.3 14h2.8q.125.35.188.713t.062.787q0 2.15-1.537 3.325T12.15 20M2 12v-2h20v2zm10.05-8.15q1.65 0 2.888.812T16.85 7.15l-2.2.975q-.225-.725-.837-1.3T12.1 6.25q-1.025 0-1.7.463T9.65 8h-2.4q.05-1.725 1.363-2.937T12.05 3.85\"/>"
},
"subdirectory-arrow-right-rounded": {
"body": "<path fill=\"currentColor\" d=\"M13 19.575q0-.4.3-.7L16.175 16H7q-.825 0-1.412-.587T5 14V5q0-.425.288-.712T6 4t.713.288T7 5v9h9.175l-2.9-2.9q-.3-.3-.288-.7t.288-.7q.3-.3.7-.312t.7.287L19.3 14.3q.15.15.212.325t.063.375t-.063.375t-.212.325l-4.575 4.575q-.3.3-.712.3t-.713-.3t-.3-.7\"/>"
},
"swap-horiz": {
"body": "<path fill=\"currentColor\" d=\"m7 20l-5-5l5-5l1.4 1.425L5.825 14H13v2H5.825L8.4 18.575zm10-6l-1.4-1.425L18.175 10H11V8h7.175L15.6 5.425L17 4l5 5z\"/>"
},
"sync-alt-rounded": {
"body": "<path fill=\"currentColor\" d=\"m5.825 17l1.9 1.9q.3.3.288.7t-.313.7q-.3.275-.7.288t-.7-.288l-3.6-3.6q-.15-.15-.213-.325T2.426 16t.063-.375t.212-.325l3.6-3.6q.275-.275.688-.275t.712.275q.3.3.3.713t-.3.712L5.825 15H20q.425 0 .713.288T21 16t-.288.713T20 17zm12.35-8H4q-.425 0-.712-.288T3 8t.288-.712T4 7h14.175l-1.9-1.9q-.3-.3-.287-.7t.312-.7q.3-.275.7-.288t.7.288l3.6 3.6q.15.15.213.325t.062.375t-.062.375t-.213.325l-3.6 3.6q-.275.275-.687.275T16.3 12.3q-.3-.3-.3-.712t.3-.713z\"/>"
},
"system-update-alt-rounded": {
"body": "<path fill=\"currentColor\" d=\"M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h4q.425 0 .713.288T9 5t-.288.713T8 6H4v12h16V6h-4q-.425 0-.712-.288T15 5t.288-.712T16 4h4q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm7-8.4V5q0-.425.288-.712T12 4t.713.288T13 5v6.6l1.9-1.9q.275-.275.7-.275t.7.275t.275.7t-.275.7l-3.6 3.6q-.3.3-.7.3t-.7-.3l-3.6-3.6q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275z\"/>"
},
"text-fields": {
"body": "<path fill=\"currentColor\" d=\"M7 20V7H2V4h13v3h-5v13zm9 0v-8h-3V9h9v3h-3v8z\"/>"
},
"text-fields-rounded": {
"body": "<path fill=\"currentColor\" d=\"M7.438 19.563Q7 19.125 7 18.5V7H3.5q-.625 0-1.062-.437T2 5.5t.438-1.062T3.5 4h10q.625 0 1.063.438T15 5.5t-.437 1.063T13.5 7H10v11.5q0 .625-.437 1.063T8.5 20t-1.062-.437m9 0Q16 19.125 16 18.5V12h-1.5q-.625 0-1.062-.437T13 10.5t.438-1.062T14.5 9h6q.625 0 1.063.438T22 10.5t-.437 1.063T20.5 12H19v6.5q0 .625-.437 1.063T17.5 20t-1.062-.437\"/>"
},
"thread-unread-rounded": {
"body": "<path fill=\"currentColor\" d=\"M17.875 6.125Q17 5.25 17 4t.875-2.125T20 1t2.125.875T23 4t-.875 2.125T20 7t-2.125-.875M13.85 21q-1.35 0-2.275-.925t-.925-2.225q0-1.9 1.538-3.438t3.837-1.912q-.075-.9-.45-1.362t-1.025-.463q-.75 0-1.625.625t-2.075 2.05q-1.95 2.325-2.862 3.025t-1.963.7q-1.275 0-2.15-.95T3 13.825t.588-2.762t1.987-3.388q.475-.65.7-1.1T6.5 5.85q0-.175-.062-.262T6.25 5.5t-.3.075t-.375.275q-.375.35-.862.375T3.875 5.9q-.375-.4-.387-.937t.387-.888q.6-.525 1.2-.8T6.25 3q1.15 0 1.95.8t.8 2q0 .725-.375 1.6t-1.25 2.1q-.95 1.35-1.412 2.375t-.463 1.8q0 .425.138.663t.387.237t.438-.137t.687-.663q.325-.35.775-.862t1.1-1.263q1.575-1.875 2.85-2.675t2.675-.8q1.675 0 2.75 1.125t1.225 3.075h1.225q.525 0 .888.362t.362.888t-.363.888t-.887.362h-1.225q-.2 2.8-1.463 4.463T13.85 21m.05-2.5q.8 0 1.35-.913T16 15.05q-1.15.275-2 1.088t-.85 1.612q0 .35.2.55t.55.2\"/>"
},
"toc-rounded": {
"body": "<path fill=\"currentColor\" d=\"M4 17q-.425 0-.712-.288T3 16t.288-.712T4 15h12q.425 0 .713.288T17 16t-.288.713T16 17zm0-4q-.425 0-.712-.288T3 12t.288-.712T4 11h12q.425 0 .713.288T17 12t-.288.713T16 13zm0-4q-.425 0-.712-.288T3 8t.288-.712T4 7h12q.425 0 .713.288T17 8t-.288.713T16 9zm16 8q-.425 0-.712-.288T19 16t.288-.712T20 15t.713.288T21 16t-.288.713T20 17m0-4q-.425 0-.712-.288T19 12t.288-.712T20 11t.713.288T21 12t-.288.713T20 13m0-4q-.425 0-.712-.288T19 8t.288-.712T20 7t.713.288T21 8t-.288.713T20 9\"/>"
},
"touch-app-rounded": {
"body": "<path fill=\"currentColor\" d=\"M10.475 22q-.7 0-1.312-.3t-1.038-.85L3.1 14.475q-.2-.225-.175-.537t.225-.513q.5-.525 1.2-.625t1.3.275L7.5 14.2V6q0-.425.288-.712T8.5 5t.725.288t.3.712v5H17q1.25 0 2.125.875T20 14v4q0 1.65-1.175 2.825T16 22zm1.5-13q-.425 0-.712-.288T10.975 8q0-.05.125-.5q.2-.35.3-.712T11.5 6q0-1.25-.875-2.125T8.5 3t-2.125.875T5.5 6q0 .425.1.788t.3.712q.075.125.1.25t.025.25q0 .425-.275.713T5.05 9q-.275 0-.512-.15t-.363-.375q-.325-.55-.5-1.175T3.5 6q0-2.075 1.463-3.537T8.5 1t3.538 1.463T13.5 6q0 .675-.175 1.3t-.5 1.175q-.125.225-.35.375t-.5.15\"/>"
},
"tune-rounded": {
"body": "<path fill=\"currentColor\" d=\"M11.288 20.713Q11 20.425 11 20v-4q0-.425.288-.712T12 15t.713.288T13 16v1h7q.425 0 .713.288T21 18t-.288.713T20 19h-7v1q0 .425-.288.713T12 21t-.712-.288M4 19q-.425 0-.712-.288T3 18t.288-.712T4 17h4q.425 0 .713.288T9 18t-.288.713T8 19zm3.288-4.288Q7 14.425 7 14v-1H4q-.425 0-.712-.288T3 12t.288-.712T4 11h3v-1q0-.425.288-.712T8 9t.713.288T9 10v4q0 .425-.288.713T8 15t-.712-.288M12 13q-.425 0-.712-.288T11 12t.288-.712T12 11h8q.425 0 .713.288T21 12t-.288.713T20 13zm3.288-4.288Q15 8.425 15 8V4q0-.425.288-.712T16 3t.713.288T17 4v1h3q.425 0 .713.288T21 6t-.288.713T20 7h-3v1q0 .425-.288.713T16 9t-.712-.288M4 7q-.425 0-.712-.288T3 6t.288-.712T4 5h8q.425 0 .713.288T13 6t-.288.713T12 7z\"/>"
},
"undo": {
"body": "<path fill=\"currentColor\" d=\"M7 19v-2h7.1q1.575 0 2.738-1T18 13.5T16.838 11T14.1 10H7.8l2.6 2.6L9 14L4 9l5-5l1.4 1.4L7.8 8h6.3q2.425 0 4.163 1.575T20 13.5t-1.737 3.925T14.1 19z\"/>"
},
"unfold-less": {
"body": "<path fill=\"currentColor\" d=\"m8.9 20l-1.4-1.4l4.5-4.5l4.5 4.5l-1.4 1.4l-3.1-3.1zM12 9.9L7.5 5.4L8.9 4L12 7.1L15.1 4l1.4 1.4z\"/>"
},
"unfold-more": {
"body": "<path fill=\"currentColor\" d=\"m12 21l-4.5-4.5l1.45-1.45L12 18.1l3.05-3.05l1.45 1.45zM8.95 9.05L7.5 7.6L12 3.1l4.5 4.5l-1.45 1.45L12 6z\"/>"
},
"upload-file-rounded": {
"body": "<path fill=\"currentColor\" d=\"M11 14.825V18q0 .425.288.713T12 19t.713-.288T13 18v-3.175l.9.9q.15.15.338.225t.375.063t.362-.088t.325-.225q.275-.3.288-.7t-.288-.7l-2.6-2.6q-.15-.15-.325-.212T12 11.425t-.375.063t-.325.212l-2.6 2.6q-.3.3-.287.7t.312.7q.3.275.7.288t.7-.288zM6 22q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h7.175q.4 0 .763.15t.637.425l4.85 4.85q.275.275.425.638t.15.762V20q0 .825-.587 1.413T18 22zm7-14q0 .425.288.713T14 9h4l-5-5z\"/>"
},
"upload-rounded": {
"body": "<path fill=\"currentColor\" d=\"M6 20q-.825 0-1.412-.587T4 18v-2q0-.425.288-.712T5 15t.713.288T6 16v2h12v-2q0-.425.288-.712T19 15t.713.288T20 16v2q0 .825-.587 1.413T18 20zm5-12.15L9.125 9.725q-.3.3-.712.288T7.7 9.7q-.275-.3-.288-.7t.288-.7l3.6-3.6q.15-.15.325-.212T12 4.425t.375.063t.325.212l3.6 3.6q.3.3.288.7t-.288.7q-.3.3-.712.313t-.713-.288L13 7.85V15q0 .425-.288.713T12 16t-.712-.288T11 15z\"/>"
},
"verified-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M10.95 12.7L9.5 11.275Q9.225 11 8.813 11t-.713.3q-.275.275-.275.7t.275.7l2.15 2.15q.3.3.7.3t.7-.3l4.25-4.25q.3-.3.287-.7t-.287-.7q-.3-.3-.712-.312t-.713.287zm-2.8 9.05L6.7 19.3l-2.75-.6q-.375-.075-.6-.387t-.175-.688L3.45 14.8l-1.875-2.15q-.25-.275-.25-.65t.25-.65L3.45 9.2l-.275-2.825q-.05-.375.175-.688t.6-.387l2.75-.6l1.45-2.45q.2-.325.55-.438t.7.038l2.6 1.1l2.6-1.1q.35-.15.7-.038t.55.438L17.3 4.7l2.75.6q.375.075.6.388t.175.687L20.55 9.2l1.875 2.15q.25.275.25.65t-.25.65L20.55 14.8l.275 2.825q.05.375-.175.688t-.6.387l-2.75.6l-1.45 2.45q-.2.325-.55.438t-.7-.038l-2.6-1.1l-2.6 1.1q-.35.15-.7.038t-.55-.438m1.3-1.8l2.55-1.1l2.6 1.1l1.4-2.4l2.75-.65l-.25-2.8l1.85-2.1l-1.85-2.15l.25-2.8l-2.75-.6l-1.45-2.4L12 5.15l-2.6-1.1L8 6.45l-2.75.6l.25 2.8L3.65 12l1.85 2.1l-.25 2.85l2.75.6zM12 12\"/>"
},
"verified-user-outline": {
"body": "<path fill=\"currentColor\" d=\"M10.95 15.55L16.6 9.9l-1.425-1.425L10.95 12.7l-2.1-2.1l-1.425 1.425zM12 22q-3.475-.875-5.737-3.988T4 11.1V5l8-3l8 3v6.1q0 3.8-2.262 6.913T12 22m0-2.1q2.6-.825 4.3-3.3t1.7-5.5V6.375l-6-2.25l-6 2.25V11.1q0 3.025 1.7 5.5t4.3 3.3m0-7.9\"/>"
},
"verified-user-rounded": {
"body": "<path fill=\"currentColor\" d=\"m10.95 12.7l-1.4-1.4q-.3-.3-.7-.3t-.7.3t-.3.713t.3.712l2.1 2.125q.3.3.7.3t.7-.3l4.25-4.25q.3-.3.3-.712t-.3-.713t-.712-.3t-.713.3zm.725 9.175q-.15-.025-.3-.075Q8 20.675 6 17.637T4 11.1V6.375q0-.625.363-1.125t.937-.725l6-2.25q.35-.125.7-.125t.7.125l6 2.25q.575.225.938.725T20 6.375V11.1q0 3.5-2 6.538T12.625 21.8q-.15.05-.3.075T12 21.9t-.325-.025\"/>"
},
"view-carousel-rounded": {
"body": "<path fill=\"currentColor\" d=\"M3 17q-.425 0-.712-.288T2 16V8q0-.425.288-.712T3 7h2q.425 0 .713.288T6 8v8q0 .425-.288.713T5 17H3Zm5 2q-.425 0-.712-.288T7 18V6q0-.425.288-.712T8 5h8q.425 0 .713.288T17 6v12q0 .425-.288.713T16 19H8Zm11-2q-.425 0-.712-.288T18 16V8q0-.425.288-.712T19 7h2q.425 0 .713.288T22 8v8q0 .425-.288.713T21 17h-2Z\"/>",
"hidden": true
},
"view-list": {
"body": "<path fill=\"currentColor\" d=\"M9 20h11q.825 0 1.413-.587T22 18v-2H9zM2 8h5V4H4q-.825 0-1.412.588T2 6zm0 6h5v-4H2zm2 6h3v-4H2v2q0 .825.588 1.413T4 20m5-6h13v-4H9zm0-6h13V6q0-.825-.587-1.412T20 4H9z\"/>"
},
"visibility": {
"body": "<path fill=\"currentColor\" d=\"M15.188 14.688Q16.5 13.375 16.5 11.5t-1.312-3.187T12 7T8.813 8.313T7.5 11.5t1.313 3.188T12 16t3.188-1.312m-5.1-1.276Q9.3 12.625 9.3 11.5t.788-1.912T12 8.8t1.913.788t.787 1.912t-.787 1.913T12 14.2t-1.912-.787m-4.738 3.55Q2.35 14.925 1 11.5q1.35-3.425 4.35-5.462T12 4t6.65 2.038T23 11.5q-1.35 3.425-4.35 5.463T12 19t-6.65-2.037\"/>"
},
"visibility-off-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"M15.175 8.325q.725.725 1.063 1.65t.237 1.9q0 .375-.275.638t-.65.262t-.638-.262t-.262-.638q.125-.65-.075-1.25T13.95 9.6t-1.025-.65t-1.275-.1q-.375 0-.638-.275t-.262-.65t.263-.637t.637-.263q.95-.1 1.875.238t1.65 1.062M12 6q-.475 0-.925.037t-.9.138q-.425.075-.763-.125t-.462-.6t.088-.775t.612-.45q.575-.125 1.163-.175T12 4q3.425 0 6.263 1.8t4.337 4.85q.1.2.15.413t.05.437t-.038.438t-.137.412q-.45 1-1.112 1.875t-1.463 1.6q-.3.275-.7.225t-.65-.4t-.212-.763t.337-.687q.6-.575 1.1-1.25t.875-1.45q-1.25-2.525-3.613-4.012T12 6m0 13q-3.35 0-6.125-1.812T1.5 12.425q-.125-.2-.187-.437T1.25 11.5t.05-.475t.175-.45q.5-1 1.163-1.912T4.15 7L2.075 4.9q-.275-.3-.262-.712T2.1 3.5t.7-.275t.7.275l17 17q.275.275.288.688t-.288.712q-.275.275-.7.275t-.7-.275l-3.5-3.45q-.875.275-1.775.413T12 19M5.55 8.4q-.725.65-1.325 1.425T3.2 11.5q1.25 2.525 3.613 4.013T12 17q.5 0 .975-.062t.975-.138l-.9-.95q-.275.075-.525.113T12 16q-1.875 0-3.188-1.312T7.5 11.5q0-.275.038-.525t.112-.525zm4.2 4.2\"/>"
},
"visibility-rounded": {
"body": "<path fill=\"currentColor\" d=\"M15.188 14.688Q16.5 13.375 16.5 11.5t-1.312-3.187T12 7T8.813 8.313T7.5 11.5t1.313 3.188T12 16t3.188-1.312m-5.1-1.276Q9.3 12.625 9.3 11.5t.788-1.912T12 8.8t1.913.788t.787 1.912t-.787 1.913T12 14.2t-1.912-.787m-4.2 3.787q-2.763-1.8-4.363-4.75q-.125-.225-.187-.462t-.063-.488t.063-.488t.187-.462q1.6-2.95 4.363-4.75T12 4t6.113 1.8t4.362 4.75q.125.225.188.463t.062.487t-.062.488t-.188.462q-1.6 2.95-4.362 4.75T12 19t-6.113-1.8\"/>"
},
"vital-signs-rounded": {
"body": "<path fill=\"currentColor\" d=\"M8.15 19.725q-.375-.275-.55-.7L5.3 13H2q-.425 0-.712-.288T1 12t.288-.712T2 11h4q.325 0 .563.175t.362.475L9 17.1l4.6-12.125q.175-.425.55-.7T15 4t.85.275t.55.7L18.7 11H22q.425 0 .713.288T23 12t-.288.713T22 13h-4q-.325 0-.562-.175t-.363-.475L15 6.9l-4.6 12.125q-.175.425-.55.7T9 20t-.85-.275\"/>"
},
"warning": {
"body": "<path fill=\"currentColor\" d=\"M1 21L12 2l11 19zm11.713-3.287Q13 17.425 13 17t-.288-.712T12 16t-.712.288T11 17t.288.713T12 18t.713-.288M11 15h2v-5h-2z\"/>"
},
"warning-rounded": {
"body": "<path fill=\"currentColor\" d=\"M2.725 21q-.275 0-.5-.137t-.35-.363t-.137-.488t.137-.512l9.25-16q.15-.25.388-.375T12 3t.488.125t.387.375l9.25 16q.15.25.138.513t-.138.487t-.35.363t-.5.137zm9.988-3.287Q13 17.425 13 17t-.288-.712T12 16t-.712.288T11 17t.288.713T12 18t.713-.288m0-3Q13 14.425 13 14v-3q0-.425-.288-.712T12 10t-.712.288T11 11v3q0 .425.288.713T12 15t.713-.288\"/>"
},
"workspace-premium-outline-rounded": {
"body": "<path fill=\"currentColor\" d=\"m12 11.925l-1.7 1.3q-.15.125-.3.013t-.1-.288l.65-2.1l-1.75-1.4q-.125-.125-.075-.287T8.95 9h2.15l.65-2.05q.05-.175.25-.175t.25.175L12.9 9h2.125q.175 0 .238.163t-.063.287l-1.775 1.4l.65 2.1q.05.175-.1.288t-.3-.013zM12 21l-4.675 1.55q-.5.175-.913-.125t-.412-.8v-6.35q-.95-1.05-1.475-2.4T4 10q0-3.35 2.325-5.675T12 2t5.675 2.325T20 10q0 1.525-.525 2.875T18 15.275v6.35q0 .5-.413.8t-.912.125zm4.25-6.75Q18 12.5 18 10t-1.75-4.25T12 4T7.75 5.75T6 10t1.75 4.25T12 16t4.25-1.75M8 20.025L12 19l4 1.025v-3.1q-.875.5-1.888.788T12 18t-2.113-.288T8 16.926zm4-1.55\"/>"
},
"workspace-premium-rounded": {
"body": "<path fill=\"currentColor\" d=\"m12 11.925l-1.7 1.3q-.15.125-.3.013t-.1-.288l.65-2.1l-1.75-1.4q-.125-.125-.075-.287T8.95 9h2.15l.65-2.05q.05-.175.25-.175t.25.175L12.9 9h2.125q.175 0 .238.163t-.063.287l-1.775 1.4l.65 2.1q.05.175-.1.288t-.3-.013zM12 21l-4.675 1.55q-.5.175-.913-.125t-.412-.8v-6.35q-.95-1.05-1.475-2.4T4 10q0-3.35 2.325-5.675T12 2t5.675 2.325T20 10q0 1.525-.525 2.875T18 15.275v6.35q0 .5-.413.8t-.912.125zm4.25-6.75Q18 12.5 18 10t-1.75-4.25T12 4T7.75 5.75T6 10t1.75 4.25T12 16t4.25-1.75\"/>"
},
"zoom-in": {
"body": "<path fill=\"currentColor\" d=\"m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14m-1-1.5v-2h-2v-2h2v-2h2v2h2v2h-2v2z\"/>"
},
"zoom-in-map-rounded": {
"body": "<path fill=\"currentColor\" d=\"m7 18.4l-2.4 2.4q-.275.275-.7.275t-.7-.275t-.275-.7t.275-.7L5.6 17H4q-.425 0-.713-.287T3 16t.288-.712T4 15h4q.425 0 .713.288T9 16v4q0 .425-.288.713T8 21t-.712-.288T7 20zm10 0V20q0 .425-.288.713T16 21t-.712-.288T15 20v-4q0-.425.288-.712T16 15h4q.425 0 .713.288T21 16t-.288.713T20 17h-1.6l2.4 2.4q.275.275.275.7t-.275.7t-.7.275t-.7-.275zM5.6 7L3.2 4.6q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275L7 5.6V4q0-.425.288-.713T8 3t.713.288T9 4v4q0 .425-.288.713T8 9H4q-.425 0-.712-.288T3 8t.288-.712T4 7zm12.8 0H20q.425 0 .713.288T21 8t-.288.713T20 9h-4q-.425 0-.712-.288T15 8V4q0-.425.288-.712T16 3t.713.288T17 4v1.6l2.4-2.4q.275-.275.7-.275t.7.275t.275.7t-.275.7z\"/>"
},
"zoom-out": {
"body": "<path fill=\"currentColor\" d=\"m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14M7 10.5v-2h5v2z\"/>"
}
},
"lastModified": 1773740798,
"aliases": {
"cloud-upload": {
"parent": "backup"
},
"crop-square": {
"parent": "square-rounded"
}
},
"width": 24,
"height": 24
}