From ab6d659cd4aeaa1ee9b8ae3844c579798b88c641 Mon Sep 17 00:00:00 2001 From: Jos Nienhuis <6952249+joszz@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:07:41 +0200 Subject: [PATCH 1/4] refactor(backend): derive repeated definitions from their source Four places restated something the code already knew. The repository's "does this row carry telemetry" filter listed seventy fields by hand, next to merge loops that read the same fields off the model by reflection. A field added to the model was silently treated as empty until someone extended that list. Both filters are now built from a type: the general one from the model, the chart one from TelemetryHistoryPoint, whose field list it was copying. The API and the Worker each configured Serilog and the tyre pressure thresholds with identical blocks. They now share GarageStack.Core.Configuration. HostingExtensions, and the thresholds fall back per value, so a deployment that passes an empty string gets the app's defaults rather than zeroes. Three Worker services read db.Vehicles directly, past IVehicleRepository. They go through the repository now; the one write that made this awkward, the parked-at timestamp, has a repository method rather than a tracked entity save. The frontend detected the drivetrain by parsing hw_version itself, duplicating VehicleTypeHelper. The vehicle list endpoint now reports the detected type. Also fixes a flaky test: the command gate's hold window was measured with DateTime.UtcNow, whose ~15ms tick can read a full wait back as slightly short. Co-Authored-By: Claude Opus 5 --- .../Endpoints/VehicleEndpoints.cs | 16 +++- src/GarageStack.Api/Program.cs | 27 +----- src/GarageStack.Api/appsettings.json | 8 -- .../Configuration/HostingExtensions.cs | 83 +++++++++++++++++++ src/GarageStack.Core/GarageStack.Core.csproj | 9 ++ .../Interfaces/IVehicleRepository.cs | 6 ++ .../Models/TelemetryHistoryPoint.cs | 3 +- .../Demo/DemoVehicleRepository.cs | 3 + .../Repositories/TelemetryRepository.cs | 82 ++++++++---------- .../Repositories/VehicleRepository.cs | 8 ++ .../HostingExtensionsTests.cs | 73 ++++++++++++++++ .../VehicleCommandGateTests.cs | 18 ++-- .../VehicleRepositoryTests.cs | 29 +++++++ src/GarageStack.Worker/Program.cs | 25 +----- .../Services/MaintenanceCheckService.cs | 3 +- .../Services/PoiPreCachingService.cs | 3 +- .../Services/PushNotificationCheckService.cs | 10 +-- src/GarageStack.Worker/appsettings.json | 5 -- 18 files changed, 290 insertions(+), 121 deletions(-) create mode 100644 src/GarageStack.Core/Configuration/HostingExtensions.cs create mode 100644 src/GarageStack.Tests/HostingExtensionsTests.cs diff --git a/src/GarageStack.Api/Endpoints/VehicleEndpoints.cs b/src/GarageStack.Api/Endpoints/VehicleEndpoints.cs index 475d1aac..098c8163 100644 --- a/src/GarageStack.Api/Endpoints/VehicleEndpoints.cs +++ b/src/GarageStack.Api/Endpoints/VehicleEndpoints.cs @@ -65,7 +65,8 @@ public static IEndpointRouteBuilder MapVehicleEndpoints(this IEndpointRouteBuild group.MapGet("/", async (IVehicleRepository vehicles, CancellationToken ct) => { var all = await vehicles.GetAllAsync(ct); - return Results.Ok(all.Select(v => new VehicleListItemDto(v.Id, v.Vin, v.Model, v.Series, v.CreatedAt))); + return Results.Ok(all.Select(v => new VehicleListItemDto( + v.Id, v.Vin, v.Model, v.Series, v.CreatedAt, VehicleTypeHelper.GetVehicleType(v)))); }) .WithSummary("List all vehicles"); @@ -249,4 +250,15 @@ public static IEndpointRouteBuilder MapVehicleEndpoints(this IEndpointRouteBuild }; } -public record VehicleListItemDto(int Id, string Vin, string? Model, string? Series, DateTime CreatedAt); +/// +/// A vehicle as the list endpoint returns it. VehicleType is the drivetrain detected from +/// the vehicle's reported hardware version (hev, phev, bev, or unknown while nothing has reported +/// one), served here so every client reads the same answer instead of parsing it themselves. +/// +public record VehicleListItemDto( + int Id, + string Vin, + string? Model, + string? Series, + DateTime CreatedAt, + string VehicleType); diff --git a/src/GarageStack.Api/Program.cs b/src/GarageStack.Api/Program.cs index 64fa8b59..77a8f262 100644 --- a/src/GarageStack.Api/Program.cs +++ b/src/GarageStack.Api/Program.cs @@ -17,11 +17,8 @@ using Microsoft.EntityFrameworkCore; using Scalar.AspNetCore; using Serilog; -using Serilog.Events; -Log.Logger = new LoggerConfiguration() - .WriteTo.Console() - .CreateBootstrapLogger(); +Log.Logger = HostingExtensions.CreateBootstrapLogger(); try { @@ -32,22 +29,7 @@ if (builder.Environment.IsEnvironment("Demo")) builder.Configuration.AddUserSecrets(optional: true); - var debugLogs = string.Equals(builder.Configuration["DEBUG_LOGS"], "true", StringComparison.OrdinalIgnoreCase); - - builder.Services.AddSerilog((_, config) => - { - config.ReadFrom.Configuration(builder.Configuration) - .WriteTo.Console() - .WriteTo.File( - "logs/api-.log", - rollingInterval: RollingInterval.Day, - retainedFileCountLimit: 30); - - if (debugLogs) - config.MinimumLevel.Debug() - .MinimumLevel.Override("Microsoft", LogEventLevel.Information) - .MinimumLevel.Override("System", LogEventLevel.Warning); - }); + builder.Services.AddGarageStackSerilog(builder.Configuration, "api"); // Pin the key ring to a fixed, CWD-relative path (mirrors "logs/api-.log" above) instead of // relying on ASP.NET Core's implicit default, which resolves against the OS user profile. @@ -96,8 +78,7 @@ opts.SerializerOptions.Converters.Add(new FiniteDoubleConverter()); }); - builder.Services.AddSingleton(builder.Configuration.GetSection("TyrePressure").Get() - ?? TyrePressureThresholds.Default); + builder.Services.AddTyrePressureThresholds(builder.Configuration); builder.Services.AddMemoryCache(); builder.Services.AddScoped(); @@ -111,7 +92,7 @@ // Requests per minute per client IP across the whole API. Configurable because the right // number depends on the deployment: a household sharing one NAT address, or a browser test // run driving several pages in parallel, bursts well past what a single tab needs. - var globalPermitsPerMinute = builder.Configuration.GetValue("RateLimits:GlobalPerMinute", 120); + var globalPermitsPerMinute = builder.Configuration.IntegerOrDefault("RateLimits:GlobalPerMinute", 120); if (globalPermitsPerMinute < 1) { throw new InvalidOperationException( diff --git a/src/GarageStack.Api/appsettings.json b/src/GarageStack.Api/appsettings.json index a43e6c97..40172d20 100644 --- a/src/GarageStack.Api/appsettings.json +++ b/src/GarageStack.Api/appsettings.json @@ -8,14 +8,6 @@ "Overpass": { "BaseUrl": "https://overpass-api.de/api/interpreter" }, - "RateLimits": { - "GlobalPerMinute": 120 - }, - "TyrePressure": { - "LowBar": 2.2, - "GoodBar": 2.6, - "HighBar": 3.2 - }, "Serilog": { "MinimumLevel": { "Default": "Information", diff --git a/src/GarageStack.Core/Configuration/HostingExtensions.cs b/src/GarageStack.Core/Configuration/HostingExtensions.cs new file mode 100644 index 00000000..0a5a0fe7 --- /dev/null +++ b/src/GarageStack.Core/Configuration/HostingExtensions.cs @@ -0,0 +1,83 @@ +using System.Globalization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using Serilog.Events; + +namespace GarageStack.Core.Configuration; + +/// +/// Startup wiring the API and the Worker share. Both are hosts over the same deployment: they +/// read the same environment, log the same way (console plus a rolling file, with DEBUG_LOGS +/// opening the taps) and colour tyre pressure by the same thresholds. +/// +public static class HostingExtensions +{ + private const int RetainedLogFileCount = 30; + + /// + /// Logger for the window before the host exists, so a failure while reading configuration + /// still reaches the console instead of vanishing. Replaced by the configured logger as soon + /// as the host is built. + /// + public static ILogger CreateBootstrapLogger() => + new LoggerConfiguration().WriteTo.Console().CreateBootstrapLogger(); + + /// + /// Logging as both services do it: console plus a daily rolling file kept for a month, with + /// levels read from configuration and DEBUG_LOGS=true turning on debug output. The + /// logFilePrefix names that file, so "api" writes logs/api-20260916.log. + /// + public static IServiceCollection AddGarageStackSerilog( + this IServiceCollection services, IConfiguration configuration, string logFilePrefix) + { + var debugLogs = string.Equals(configuration["DEBUG_LOGS"], "true", StringComparison.OrdinalIgnoreCase); + + return services.AddSerilog((_, config) => + { + config.ReadFrom.Configuration(configuration) + .WriteTo.Console() + .WriteTo.File( + $"logs/{logFilePrefix}-.log", + rollingInterval: RollingInterval.Day, + retainedFileCountLimit: RetainedLogFileCount); + + if (debugLogs) + config.MinimumLevel.Debug() + .MinimumLevel.Override("Microsoft", LogEventLevel.Information) + .MinimumLevel.Override("System", LogEventLevel.Warning); + }); + } + + /// + /// Tyre pressure thresholds from configuration, falling back per value to the generic + /// passenger-car defaults. The API colour-codes the dashboard with them and the Worker decides + /// whether a pressure is worth a notification, so both need the same numbers. + /// + public static IServiceCollection AddTyrePressureThresholds( + this IServiceCollection services, IConfiguration configuration) + { + var section = configuration.GetSection("TyrePressure"); + var defaults = TyrePressureThresholds.Default; + + return services.AddSingleton(new TyrePressureThresholds( + Value(section, "LowBar", defaults.LowBar), + Value(section, "GoodBar", defaults.GoodBar), + Value(section, "HighBar", defaults.HighBar))); + + static double Value(IConfiguration section, string key, double fallback) => + double.TryParse(section[key], NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : fallback; + } + + /// + /// An integer setting, falling back to when it is unset, blank or + /// unparseable. Deployments pass configuration through environment variables, where "unset" + /// usually arrives as an empty string rather than as a missing key. + /// + public static int IntegerOrDefault(this IConfiguration configuration, string key, int fallback) => + int.TryParse(configuration[key], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : fallback; +} diff --git a/src/GarageStack.Core/GarageStack.Core.csproj b/src/GarageStack.Core/GarageStack.Core.csproj index d8de4ad8..1123a77d 100644 --- a/src/GarageStack.Core/GarageStack.Core.csproj +++ b/src/GarageStack.Core/GarageStack.Core.csproj @@ -1,5 +1,14 @@ + + + + + + + + + diff --git a/src/GarageStack.Core/Interfaces/IVehicleRepository.cs b/src/GarageStack.Core/Interfaces/IVehicleRepository.cs index fb48fb4a..8fe832d7 100644 --- a/src/GarageStack.Core/Interfaces/IVehicleRepository.cs +++ b/src/GarageStack.Core/Interfaces/IVehicleRepository.cs @@ -25,4 +25,10 @@ public interface IVehicleRepository Task SetConfigValueAsync(int vehicleId, string key, string value, CancellationToken ct = default); Task SetModelAsync(int vehicleId, string model, CancellationToken ct = default); + + /// + /// Records when the vehicle was last parked, so the "parked recently" grace period survives + /// a Worker restart. + /// + Task SetLastParkedAtAsync(int vehicleId, DateTime parkedAt, CancellationToken ct = default); } diff --git a/src/GarageStack.Core/Models/TelemetryHistoryPoint.cs b/src/GarageStack.Core/Models/TelemetryHistoryPoint.cs index 9dd60c9e..daf76036 100644 --- a/src/GarageStack.Core/Models/TelemetryHistoryPoint.cs +++ b/src/GarageStack.Core/Models/TelemetryHistoryPoint.cs @@ -6,7 +6,8 @@ namespace GarageStack.Core.Models; /// The slice of a the statistics charts actually read. The /// history endpoint returns these instead of full snapshots: a 90-day range can hold hundreds of /// points, and every field the charts ignore would otherwise be loaded from the database and -/// serialized to the browser for nothing. Keep this in step with TelemetryRepository.HasChartData. +/// serialized to the browser for nothing. The repository reads this type's field list to decide +/// which rows are worth returning, so that filter follows any change made here. /// public sealed record TelemetryHistoryPoint( DateTime RecordedAt, diff --git a/src/GarageStack.Data/Demo/DemoVehicleRepository.cs b/src/GarageStack.Data/Demo/DemoVehicleRepository.cs index a50ee523..43b8c77d 100644 --- a/src/GarageStack.Data/Demo/DemoVehicleRepository.cs +++ b/src/GarageStack.Data/Demo/DemoVehicleRepository.cs @@ -30,4 +30,7 @@ public Task SetModelAsync(int vehicleId, string model, CancellationToken ct = de public Task SetConfigValueAsync(int vehicleId, string key, string value, CancellationToken ct = default) => Task.CompletedTask; + + public Task SetLastParkedAtAsync(int vehicleId, DateTime parkedAt, CancellationToken ct = default) => + Task.CompletedTask; } diff --git a/src/GarageStack.Data/Repositories/TelemetryRepository.cs b/src/GarageStack.Data/Repositories/TelemetryRepository.cs index 7b60f34a..f987443f 100644 --- a/src/GarageStack.Data/Repositories/TelemetryRepository.cs +++ b/src/GarageStack.Data/Repositories/TelemetryRepository.cs @@ -72,12 +72,32 @@ private static PropertyAccessor BuildAccessor(PropertyInfo prop) return new PropertyAccessor(prop.Name, getter, setter); } - private static readonly PropertyAccessor[] MergeableProperties = typeof(TelemetrySnapshot) + private static readonly PropertyInfo[] MergeablePropertyInfos = typeof(TelemetrySnapshot) .GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanRead && p.CanWrite && !NonMergeableProperties.Contains(p.Name)) - .Select(BuildAccessor) .ToArray(); + private static readonly PropertyAccessor[] MergeableProperties = + [.. MergeablePropertyInfos.Select(BuildAccessor)]; + + /// + /// Builds "any of these fields is set" as an expression tree EF can translate to SQL. + /// Non-nullable properties are left out: they always have a value, so they say nothing about + /// whether a row carries telemetry. + /// + private static Expression> AnyFieldSet(IEnumerable properties) + { + var snapshot = Expression.Parameter(typeof(TelemetrySnapshot), "s"); + var conditions = properties + .Where(p => !p.PropertyType.IsValueType || Nullable.GetUnderlyingType(p.PropertyType) is not null) + .Select(p => (Expression)Expression.NotEqual( + Expression.Property(snapshot, p), + Expression.Constant(null, p.PropertyType))); + + return Expression.Lambda>( + conditions.Aggregate(Expression.OrElse), snapshot); + } + // Daily counters reset at midnight - a stale value from a prior day must not be carried // forward into "today's" merged snapshot, so these two are merged with an extra date guard. private static readonly HashSet DailyCounterFields = @@ -107,55 +127,23 @@ private static void ApplyFirstNonNullFields(TelemetrySnapshot target, TelemetryS } } + // A row is worth reading when any mergeable field carries a value. Derived from the model for + // the same reason the merge loops are: hand-listing seventy fields here means a new telemetry + // field is silently treated as empty until someone remembers to add it. private static readonly Expression> HasData = - s => s.FuelLevelPercent != null || s.FuelRangeKm != null || - s.OdometerKm != null || s.EngineRunning != null || s.Speed != null || - s.IsLocked != null || s.ClimateOn != null || - s.DriverDoorOpen != null || s.PassengerDoorOpen != null || - s.RearLeftDoorOpen != null || s.RearRightDoorOpen != null || - s.TrunkOpen != null || s.BonnetOpen != null || - s.DriverWindowOpen != null || s.PassengerWindowOpen != null || - s.RearLeftWindowOpen != null || s.RearRightWindowOpen != null || - s.SunRoofOpen != null || - s.Latitude != null || s.Longitude != null || s.Heading != null || - s.BatteryVoltage != null || - s.InteriorTemperature != null || s.ExteriorTemperature != null || - s.RemoteTemperature != null || - s.EvSocPercent != null || s.IsCharging != null || - s.TyrePressureFrontLeft != null || s.TyrePressureFrontRight != null || - s.TyrePressureRearLeft != null || s.TyrePressureRearRight != null || - s.MileageOfTheDay != null || s.PowerUsageOfDay != null || - s.MileageSinceLastCharge != null || - s.HvVoltage != null || s.HvCurrent != null || s.HvPower != null || - s.HvSocKwh != null || s.HvTotalCapacityKwh != null || - s.PowerUsageSinceLastCharge != null || - s.ChargerConnected != null || s.HvBatteryActive != null || - s.LightsMainBeam != null || s.LightsDippedBeam != null || s.LightsSide != null || - s.HeatedSeatFrontLeft != null || s.HeatedSeatFrontRight != null || - s.RearWindowDefroster != null || - s.IsAvailable != null || s.LastVehicleStateAt != null || s.LastChargeStateAt != null || - s.CurrentJourneyDistance != null || - s.ChargingType != null || s.ChargingCableLock != null || s.RemainingChargingTime != null || - s.BmsChargeStatus != null || s.OnboardChargerPlugStatus != null || s.OffboardChargerPlugStatus != null || - s.LastChargeEndingPower != null || s.ChargingLastEndAt != null || - s.ChargingScheduleMode != null || s.ChargingScheduleStartTime != null || s.ChargingScheduleEndTime != null || - s.ObcCurrent != null || s.ObcVoltage != null || s.ObcPowerSinglePhase != null || s.ObcPowerThreePhase != null || - s.BatteryHeating != null || s.BatteryHeatingScheduleMode != null || s.BatteryHeatingScheduleStartTime != null || - s.Elevation != null; + AnyFieldSet(MergeablePropertyInfos); // Chart history excludes GPS-only rows: latitude/longitude arrive every minute during driving - // and inflate the row count, causing the stride downsampler to skip the sparser fuel/EV/kWh rows. - // GPS data for routes belongs to the trips endpoint, not chart history. The fields listed here - // are exactly the ones TelemetryHistoryPoint carries. + // and inflate the row count, causing the stride downsampler to skip the sparser fuel/EV/kWh + // rows. GPS data for routes belongs to the trips endpoint, not chart history. The fields that + // count are exactly the ones TelemetryHistoryPoint carries, read from that type so the two + // cannot drift apart. private static readonly Expression> HasChartData = - s => s.FuelLevelPercent != null || s.EvSocPercent != null || - s.PowerUsageOfDay != null || s.BatteryVoltage != null || - s.ClimateOn != null || s.IsCharging != null || - s.TyrePressureFrontLeft != null || s.TyrePressureFrontRight != null || - s.TyrePressureRearLeft != null || s.TyrePressureRearRight != null || - s.MileageOfTheDay != null || s.MileageSinceLastCharge != null || - s.HvSocKwh != null || s.HvTotalCapacityKwh != null || - s.PowerUsageSinceLastCharge != null; + AnyFieldSet(typeof(TelemetryHistoryPoint) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => typeof(TelemetrySnapshot).GetProperty(p.Name) + ?? throw new InvalidOperationException( + $"TelemetryHistoryPoint.{p.Name} has no matching TelemetrySnapshot property."))); public async Task AddAsync(TelemetrySnapshot snapshot, CancellationToken ct = default) { diff --git a/src/GarageStack.Data/Repositories/VehicleRepository.cs b/src/GarageStack.Data/Repositories/VehicleRepository.cs index da0cbf77..8b58004e 100644 --- a/src/GarageStack.Data/Repositories/VehicleRepository.cs +++ b/src/GarageStack.Data/Repositories/VehicleRepository.cs @@ -62,6 +62,14 @@ public async Task SetModelAsync(int vehicleId, string model, CancellationToken c await db.SaveChangesAsync(ct); } + public async Task SetLastParkedAtAsync(int vehicleId, DateTime parkedAt, CancellationToken ct = default) + { + var vehicle = await db.Vehicles.FindAsync([vehicleId], ct); + if (vehicle is null || vehicle.LastParkedAt == parkedAt) return; + vehicle.LastParkedAt = parkedAt; + await db.SaveChangesAsync(ct); + } + public async Task SetConfigValueAsync(int vehicleId, string key, string value, CancellationToken ct = default) { var vehicle = await db.Vehicles.FindAsync([vehicleId], ct); diff --git a/src/GarageStack.Tests/HostingExtensionsTests.cs b/src/GarageStack.Tests/HostingExtensionsTests.cs new file mode 100644 index 00000000..b13401e7 --- /dev/null +++ b/src/GarageStack.Tests/HostingExtensionsTests.cs @@ -0,0 +1,73 @@ +using GarageStack.Core.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace GarageStack.Tests; + +/// +/// Deployments configure the API and the Worker through environment variables, where an unset +/// setting arrives as an empty string rather than as a missing key. These cover that: the app +/// holds the defaults, so the compose files and the all-in-one entrypoint no longer restate them. +/// +public class HostingExtensionsTests +{ + private static IConfiguration Config(params (string Key, string? Value)[] settings) => + new ConfigurationBuilder() + .AddInMemoryCollection(settings.Select(s => new KeyValuePair(s.Key, s.Value))) + .Build(); + + private static TyrePressureThresholds Resolve(IConfiguration configuration) => + new ServiceCollection() + .AddTyrePressureThresholds(configuration) + .BuildServiceProvider() + .GetRequiredService(); + + [Fact] + public void TyrePressureThresholds_WithNothingConfigured_UsesTheBuiltInDefaults() + { + var thresholds = Resolve(Config()); + + Assert.Equal(TyrePressureThresholds.Default, thresholds); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not a number")] + public void TyrePressureThresholds_WithAnUnusableValue_FallsBackToTheDefault(string configured) + { + var thresholds = Resolve(Config(("TyrePressure:LowBar", configured))); + + Assert.Equal(TyrePressureThresholds.Default.LowBar, thresholds.LowBar); + } + + [Fact] + public void TyrePressureThresholds_OverridesOnlyTheValuesThatAreSet() + { + var thresholds = Resolve(Config(("TyrePressure:GoodBar", "2.55"))); + + Assert.Equal(2.55, thresholds.GoodBar); + Assert.Equal(TyrePressureThresholds.Default.LowBar, thresholds.LowBar); + Assert.Equal(TyrePressureThresholds.Default.HighBar, thresholds.HighBar); + } + + [Fact] + public void TyrePressureThresholds_ReadsDecimalsThesameWayInEveryLocale() + { + var thresholds = Resolve(Config(("TyrePressure:HighBar", "3.15"))); + + Assert.Equal(3.15, thresholds.HighBar); + } + + [Theory] + [InlineData(null, 120)] + [InlineData("", 120)] + [InlineData("nonsense", 120)] + [InlineData("500", 500)] + public void IntegerOrDefault_FallsBackUnlessTheValueIsUsable(string? configured, int expected) + { + var configuration = Config(("RateLimits:GlobalPerMinute", configured)); + + Assert.Equal(expected, configuration.IntegerOrDefault("RateLimits:GlobalPerMinute", 120)); + } +} diff --git a/src/GarageStack.Tests/VehicleCommandGateTests.cs b/src/GarageStack.Tests/VehicleCommandGateTests.cs index 11d47c1d..0dcb5bab 100644 --- a/src/GarageStack.Tests/VehicleCommandGateTests.cs +++ b/src/GarageStack.Tests/VehicleCommandGateTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using GarageStack.Api; namespace GarageStack.Tests; @@ -23,22 +24,29 @@ public async Task RunAsync_SameVin_WaitsForHoldWindowBeforeNextCommand() var holdDuration = TimeSpan.FromMilliseconds(150); var gate = new VehicleCommandGate(holdDuration); - var firstPublishedAt = DateTime.MinValue; - var secondStartedAt = DateTime.MinValue; + // Stopwatch, not DateTime.UtcNow: the system clock ticks about once every 15ms on + // Windows, so a wait of exactly the hold duration can read back as slightly less. + // Task.Delay rounds to that same tick and may fire just inside it, hence the slack: + // the point being tested is that the second command waits for the window, not that the + // timer is accurate to the millisecond. + var timerSlack = TimeSpan.FromMilliseconds(16); + var firstPublishedAt = 0L; + var secondStartedAt = 0L; await gate.RunAsync("VIN1", () => { - firstPublishedAt = DateTime.UtcNow; + firstPublishedAt = Stopwatch.GetTimestamp(); return Task.CompletedTask; }, ct); await gate.RunAsync("VIN1", () => { - secondStartedAt = DateTime.UtcNow; + secondStartedAt = Stopwatch.GetTimestamp(); return Task.CompletedTask; }, ct); - Assert.True(secondStartedAt - firstPublishedAt >= holdDuration); + var waited = Stopwatch.GetElapsedTime(firstPublishedAt, secondStartedAt); + Assert.True(waited >= holdDuration - timerSlack, $"second command ran after {waited.TotalMilliseconds}ms"); } [Fact] diff --git a/src/GarageStack.Tests/VehicleRepositoryTests.cs b/src/GarageStack.Tests/VehicleRepositoryTests.cs index 6ce96d1a..878fc077 100644 --- a/src/GarageStack.Tests/VehicleRepositoryTests.cs +++ b/src/GarageStack.Tests/VehicleRepositoryTests.cs @@ -149,6 +149,35 @@ public async Task SetModelAsync_NonExistingVehicle_DoesNothing() await new VehicleRepository(db).SetModelAsync(9999, "SomeModel", ct); } + // ── SetLastParkedAtAsync ────────────────────────────────────────────────── + + [Fact] + public async Task SetLastParkedAtAsync_PersistsTheParkingTime() + { + var ct = TestContext.Current.CancellationToken; + await using var db = CreateDb(); + var vehicle = new Vehicle { Vin = "PVIN001" }; + db.Vehicles.Add(vehicle); + await db.SaveChangesAsync(ct); + var parkedAt = new DateTime(2026, 9, 16, 8, 30, 0, DateTimeKind.Utc); + + await new VehicleRepository(db).SetLastParkedAtAsync(vehicle.Id, parkedAt, ct); + + var updated = await db.Vehicles.FindAsync([vehicle.Id], ct); + Assert.Equal(parkedAt, updated!.LastParkedAt); + } + + [Fact] + public async Task SetLastParkedAtAsync_UnknownVehicle_DoesNothing() + { + var ct = TestContext.Current.CancellationToken; + await using var db = CreateDb(); + + await new VehicleRepository(db).SetLastParkedAtAsync(4242, DateTime.UtcNow, ct); + + Assert.Empty(db.Vehicles); + } + // ── SetConfigValueAsync ─────────────────────────────────────────────────── [Fact] diff --git a/src/GarageStack.Worker/Program.cs b/src/GarageStack.Worker/Program.cs index 4321f118..39653fac 100644 --- a/src/GarageStack.Worker/Program.cs +++ b/src/GarageStack.Worker/Program.cs @@ -5,32 +5,14 @@ using GarageStack.Worker.Mqtt; using GarageStack.Worker.Services; using Serilog; -using Serilog.Events; -Log.Logger = new LoggerConfiguration() - .WriteTo.Console() - .CreateBootstrapLogger(); +Log.Logger = HostingExtensions.CreateBootstrapLogger(); try { var builder = Host.CreateApplicationBuilder(args); - var debugLogs = string.Equals(builder.Configuration["DEBUG_LOGS"], "true", StringComparison.OrdinalIgnoreCase); - - builder.Services.AddSerilog((_, config) => - { - config.ReadFrom.Configuration(builder.Configuration) - .WriteTo.Console() - .WriteTo.File( - "logs/worker-.log", - rollingInterval: RollingInterval.Day, - retainedFileCountLimit: 30); - - if (debugLogs) - config.MinimumLevel.Debug() - .MinimumLevel.Override("Microsoft", LogEventLevel.Information) - .MinimumLevel.Override("System", LogEventLevel.Warning); - }); + builder.Services.AddGarageStackSerilog(builder.Configuration, "worker"); var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("DefaultConnection is not configured."); @@ -44,8 +26,7 @@ builder.Services.AddGarageStackData(connectionString); builder.Services.Configure(builder.Configuration.GetSection(MqttOptions.SectionName)); - builder.Services.AddSingleton(builder.Configuration.GetSection("TyrePressure").Get() - ?? TyrePressureThresholds.Default); + builder.Services.AddTyrePressureThresholds(builder.Configuration); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/src/GarageStack.Worker/Services/MaintenanceCheckService.cs b/src/GarageStack.Worker/Services/MaintenanceCheckService.cs index 33bb7a42..b4648773 100644 --- a/src/GarageStack.Worker/Services/MaintenanceCheckService.cs +++ b/src/GarageStack.Worker/Services/MaintenanceCheckService.cs @@ -43,8 +43,9 @@ private async Task CheckAndNotifyAsync(CancellationToken ct) using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var telemetry = scope.ServiceProvider.GetRequiredService(); + var vehicleRepo = scope.ServiceProvider.GetRequiredService(); - var vehicles = await db.Vehicles.ToListAsync(ct); + var vehicles = await vehicleRepo.GetAllAsync(ct); // One query for every vehicle's items instead of one query per vehicle (mirrors the // grouped-lookup pattern PoiPreCachingService already uses for latest-location data). diff --git a/src/GarageStack.Worker/Services/PoiPreCachingService.cs b/src/GarageStack.Worker/Services/PoiPreCachingService.cs index b10709e9..4c22a28a 100644 --- a/src/GarageStack.Worker/Services/PoiPreCachingService.cs +++ b/src/GarageStack.Worker/Services/PoiPreCachingService.cs @@ -44,8 +44,9 @@ private async Task PreCacheAllVehiclesAsync(CancellationToken ct) using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var repository = scope.ServiceProvider.GetRequiredService(); + var vehicleRepo = scope.ServiceProvider.GetRequiredService(); - var vehicles = await db.Vehicles.ToListAsync(ct); + var vehicles = await vehicleRepo.GetAllAsync(ct); // Single query for the latest known location of every vehicle instead of one query per // vehicle: the RecordedAt == MAX(RecordedAt) correlated subquery reliably translates to SQL. diff --git a/src/GarageStack.Worker/Services/PushNotificationCheckService.cs b/src/GarageStack.Worker/Services/PushNotificationCheckService.cs index 70b907de..bf05fa8e 100644 --- a/src/GarageStack.Worker/Services/PushNotificationCheckService.cs +++ b/src/GarageStack.Worker/Services/PushNotificationCheckService.cs @@ -4,7 +4,6 @@ using GarageStack.Core.Models; using GarageStack.Data; using GarageStack.Data.Extensions; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; namespace GarageStack.Worker.Services; @@ -73,9 +72,11 @@ private async Task CheckAndNotifyAsync(CancellationToken ct) { using var scope = scopeFactory.CreateScope(); var telemetry = scope.ServiceProvider.GetRequiredService(); + var vehicleRepo = scope.ServiceProvider.GetRequiredService(); + // Still needed directly for the notification-history lookup behind the cooldown gate. var db = scope.ServiceProvider.GetRequiredService(); - var vehicles = await db.Vehicles.ToListAsync(ct); + var vehicles = await vehicleRepo.GetAllAsync(ct); foreach (var vehicle in vehicles) { @@ -93,10 +94,7 @@ private async Task CheckAndNotifyAsync(CancellationToken ct) CheckChargingComplete(snapshot, vehicle.Vin, vehicleType, alerts); var justParked = CheckEngineStart(snapshot, vehicle.Vin, alerts); if (justParked) - { - vehicle.LastParkedAt = _lastParkedAt[vehicle.Vin]; - await db.SaveChangesAsync(ct); - } + await vehicleRepo.SetLastParkedAtAsync(vehicle.Id, _lastParkedAt[vehicle.Vin], ct); var withinParkingGrace = _lastParkedAt.TryGetValue(vehicle.Vin, out var parkedAt) && DateTime.UtcNow - parkedAt < _parkingGrace; diff --git a/src/GarageStack.Worker/appsettings.json b/src/GarageStack.Worker/appsettings.json index 12c607d8..ca9c8060 100644 --- a/src/GarageStack.Worker/appsettings.json +++ b/src/GarageStack.Worker/appsettings.json @@ -6,11 +6,6 @@ "Host": "mosquitto", "Port": 1883 }, - "TyrePressure": { - "LowBar": 2.2, - "GoodBar": 2.6, - "HighBar": 3.2 - }, "Serilog": { "MinimumLevel": { "Default": "Information", From 5f9d7d9b19ea3695d3c41ebbdc9fb42f2bc966c5 Mon Sep 17 00:00:00 2001 From: Jos Nienhuis <6952249+joszz@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:09:49 +0200 Subject: [PATCH 2/4] refactor(frontend): one definition per thing, not four A dashboard card was spread over a defaults list, an icon map, a has-data switch and the renderer's own config table, which repeated the icons and the data checks a third time. src/cards/registry.ts now describes each card once: icon, default visibility per drivetrain, and whether the telemetry holds anything for it. The card ids, the default layout, the skeleton, the edit-mode placeholders and the render guards all read from it, so a new card is one entry plus its markup. usePoiLayers repeated the same state and loading logic three times, once per layer, with the fetch-id guard, tile bookkeeping and cluster handling copied each time. That engine now lives in composables/poiTileLayer.ts, and charging stations, fuel stations and service areas are three configurations of it, each with its own fetch, filter and marker. Layers that do not apply to the vehicle (a plug for an HEV, a tank for a BEV) fold into the layer's "enabled" instead of a watch apiece. Components looked the car up as vehicles[0] in nine places; the store now exposes activeVehicle/activeVin, and the drivetrain comes from the API rather than from parsing hw_version in the browser. StatsChartCard took bar data cast as line data. It now takes the chart kind as a prop and renders through vue-chartjs's own Chart component, so neither side lies. MapView's style block was entirely unscoped for the sake of the Leaflet markers in it; only those stay global now. Stylelint runs over the stylesheets, which is how the duplicate rule in main.css and the duplicated flex-shrink turned up. Co-Authored-By: Claude Opus 5 --- frontend/package.json | 6 +- frontend/pnpm-lock.yaml | 571 ++++++++++++++++++ frontend/src/App.vue | 4 +- frontend/src/assets/login.css | 10 +- frontend/src/assets/main.css | 147 +++-- frontend/src/assets/maintenance.css | 2 +- frontend/src/assets/map.css | 31 +- frontend/src/cards/__tests__/registry.spec.ts | 99 +++ frontend/src/cards/registry.ts | 177 ++++++ frontend/src/cards/useCardData.ts | 22 + frontend/src/components/AppFooter.vue | 2 +- .../src/components/DashboardCardContent.vue | 72 +-- frontend/src/components/DemoControlPanel.vue | 2 +- .../src/components/MaintenanceSummaryCard.vue | 2 +- frontend/src/components/PwaInstallModal.vue | 4 +- .../src/components/SkeletonCarDiagram.vue | 3 + frontend/src/components/SkeletonCard.vue | 1 + frontend/src/components/SkeletonChart.vue | 1 + .../src/components/SkeletonLocationMap.vue | 1 + frontend/src/components/StatsChartCard.vue | 17 +- .../__tests__/poiTileLayer.spec.ts | 205 +++++++ frontend/src/composables/poiTileLayer.ts | 244 ++++++++ frontend/src/composables/usePoiLayers.ts | 547 +++++------------ frontend/src/services/vehicleApi.ts | 5 + frontend/src/stores/__tests__/vehicle.spec.ts | 62 +- frontend/src/stores/settingsShared.ts | 73 +-- frontend/src/stores/vehicle.ts | 26 +- frontend/src/views/DashboardView.vue | 105 +--- frontend/src/views/MaintenanceView.vue | 2 +- frontend/src/views/MapView.vue | 41 +- frontend/src/views/StatisticsView.vue | 30 +- frontend/stylelint.config.js | 26 + 32 files changed, 1782 insertions(+), 758 deletions(-) create mode 100644 frontend/src/cards/__tests__/registry.spec.ts create mode 100644 frontend/src/cards/registry.ts create mode 100644 frontend/src/cards/useCardData.ts create mode 100644 frontend/src/composables/__tests__/poiTileLayer.spec.ts create mode 100644 frontend/src/composables/poiTileLayer.ts create mode 100644 frontend/stylelint.config.js diff --git a/frontend/package.json b/frontend/package.json index 469297f9..3199c63a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,9 +11,10 @@ "test:e2e": "playwright test", "build-only": "vite build", "type-check": "vue-tsc --build", - "lint": "pnpm run lint:oxlint && pnpm run lint:eslint", + "lint": "pnpm run lint:oxlint && pnpm run lint:eslint && pnpm run lint:css", "lint:oxlint": "oxlint . --fix", "lint:eslint": "eslint . --fix --cache", + "lint:css": "stylelint \"src/**/*.{css,vue}\" --fix", "format": "prettier --write --experimental-cli src/ e2e/" }, "dependencies": { @@ -62,7 +63,10 @@ "jsdom": "^30.0.1", "npm-run-all2": "^9.0.3", "oxlint": "~1.82.0", + "postcss-html": "^2.0.0", "prettier": "3.9.6", + "stylelint": "^17.15.0", + "stylelint-config-standard": "^40.0.0", "typescript": "~6.0.3", "vite": "^8.3.0", "vite-plugin-vue-devtools": "^8.2.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index f930fdd1..ccaf3bd6 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -141,9 +141,18 @@ importers: oxlint: specifier: ~1.82.0 version: 1.82.0 + postcss-html: + specifier: ^2.0.0 + version: 2.0.0(postcss@8.5.28) prettier: specifier: 3.9.6 version: 3.9.6 + stylelint: + specifier: ^17.15.0 + version: 17.15.0(typescript@6.0.3) + stylelint-config-standard: + specifier: ^40.0.0 + version: 40.0.0(stylelint@17.15.0(typescript@6.0.3)) typescript: specifier: ~6.0.3 version: 6.0.3 @@ -746,6 +755,14 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^4.0.0 + '@csstools/css-syntax-patches-for-csstree@1.1.14': + resolution: {integrity: sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + '@csstools/css-syntax-patches-for-csstree@1.1.7': resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} peerDependencies: @@ -758,6 +775,25 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@csstools/media-query-list-parser@5.0.0': + resolution: {integrity: sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/selector-resolve-nested@4.0.1': + resolution: {integrity: sha512-j3vdQu0XwLME5qOTWxm8cnmvsf423R2YL6DbKklCHZwkDm7UdKNu6RPlw4REIJhSlKBICY3B70/7QZdicLqZgg==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@csstools/selector-specificity@6.0.0': + resolution: {integrity: sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + '@eslint-community/eslint-utils@4.10.1': resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1410,6 +1446,10 @@ packages: cpu: [x64] os: [win32] + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1': resolution: {integrity: sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==} engines: {node: '>=12'} @@ -1748,6 +1788,18 @@ packages: alien-signals@3.2.1: resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + ansi-styles@7.0.0: resolution: {integrity: sha512-kKvt3m4uwzqL0wlkPd09CmljPJGOZZ4D0fP65sqFSvPkMRKhNi+74MgIJ5QxE6SxqB4t4KyUFGg8+n5zjo6hew==} engines: {node: '>=22'} @@ -1756,6 +1808,9 @@ packages: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-buffer-byte-length@1.0.2: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} @@ -1776,6 +1831,10 @@ packages: resolution: {integrity: sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==} engines: {node: '>=20.19.0'} + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -1878,6 +1937,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001806: resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} @@ -1896,6 +1959,16 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.10.0: + resolution: {integrity: sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==} + commander@14.0.3: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} @@ -1926,6 +1999,15 @@ packages: resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} engines: {node: '>=6.4.0'} + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1934,6 +2016,10 @@ packages: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'} + css-functions-list@3.3.3: + resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} + engines: {node: '>=12'} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -2005,6 +2091,19 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2025,6 +2124,13 @@ packages: electron-to-chromium@1.5.427: resolution: {integrity: sha512-n14zb3FdsChZ2BNobqNHAJMcP3ifFv4paox2LvCrfVAQcqGiSURgbJl+PfMpHVCNFkStnNc+RRVtPBTVW5PDgw==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -2033,6 +2139,13 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + error-stack-parser-es@1.0.5: resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} @@ -2183,6 +2296,10 @@ packages: fast-uri@3.1.7: resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2256,6 +2373,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2289,10 +2410,25 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + globalthis@1.0.4: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + globby@16.2.4: + resolution: {integrity: sha512-c8B/VNLmxRcmqqenRA9t+9IyOjf9+V6lTxPaUJLqOCONdQkWZ0ETYgX0qbtJqPsgCNusT9MZ5Jeidw8Eb9tn2g==} + engines: {node: '>=20'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2304,6 +2440,10 @@ packages: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} @@ -2340,6 +2480,13 @@ packages: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-tags@5.1.0: + resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==} + engines: {node: '>=20.10'} + + htmlparser2@9.1.0: + resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} @@ -2351,6 +2498,13 @@ packages: resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==} engines: {node: '>= 4'} + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -2366,6 +2520,9 @@ packages: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-async-function@2.1.1: resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} engines: {node: '>= 0.4'} @@ -2411,6 +2568,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -2451,6 +2612,10 @@ packages: resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} engines: {node: '>=0.10.0'} + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -2536,6 +2701,13 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + jsdom@30.0.1: resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -2550,6 +2722,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-parse-even-better-errors@6.0.0: resolution: {integrity: sha512-2/8adwnK1/+Fdjyts4r6wSpfANWw8zdNhU9U/Llk59c6O+DjSisPWPykwoL8gZmocP9Dy64S7oie2g+Mia123A==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} @@ -2581,6 +2756,10 @@ packages: keyv@5.6.0: resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} @@ -2677,6 +2856,9 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + local-pkg@1.2.1: resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} engines: {node: '>=14'} @@ -2688,6 +2870,9 @@ packages: lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -2709,6 +2894,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mathml-tag-names@4.0.0: + resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -2716,6 +2904,10 @@ packages: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} + meow@14.1.0: + resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} + engines: {node: '>=20'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2779,6 +2971,10 @@ packages: engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} hasBin: true + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + nostics@1.2.0: resolution: {integrity: sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==} @@ -2849,6 +3045,14 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -2926,10 +3130,25 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postcss-html@2.0.0: + resolution: {integrity: sha512-f2Rvw5FCollEfVj3wfN7JdQb7n2rNIthW+epw2EByio7M6P7RH0BTj8a/ODHrUXd0cmO7ychb6YniymV93182Q==} + engines: {node: ^22.12 || >=24} + peerDependencies: + postcss: ^8.5.0 + + postcss-safe-parser@7.1.0: + resolution: {integrity: sha512-1WzZxRLaAFwEh6Do+zyGpjWV3nGJNxxhuh7Ubu/q1ICImMgZJnLwhoaEpRbG4pJppp2Y1ncL9ffA2f+LhrefQg==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + postcss-selector-parser@7.1.6: resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} engines: {node: '>=4'} + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -3019,6 +3238,10 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} @@ -3131,6 +3354,14 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + smob@1.6.2: resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} engines: {node: '>=20.0.0'} @@ -3160,6 +3391,14 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + string.prototype.matchall@4.1.0: resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==} engines: {node: '>= 0.4'} @@ -3180,17 +3419,57 @@ packages: resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} engines: {node: '>=4'} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-comments@2.0.1: resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} engines: {node: '>=10'} + stylelint-config-recommended@18.0.0: + resolution: {integrity: sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint-config-standard@40.0.0: + resolution: {integrity: sha512-EznGJxOUhtWck2r6dJpbgAdPATIzvpLdK9+i5qPd4Lx70es66TkBPljSg4wN3Qnc6c4h2n+WbUrUynQ3fanjHw==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint@17.15.0: + resolution: {integrity: sha512-mWIkesYQvQjf4Kvdeu9ns0IL8/K/wtjaGxPqsPd6DlLZvaQxGysJsocxUDrBcyHQ5VleAk4uSMat4yMrVED7PA==} + engines: {node: '>=20.19.0'} + hasBin: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-hyperlinks@4.5.0: + resolution: {integrity: sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==} + engines: {node: '>=20'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + temp-dir@2.0.0: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} engines: {node: '>=8'} @@ -3325,6 +3604,10 @@ packages: resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} engines: {node: '>=4'} + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} + unique-string@2.0.0: resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} engines: {node: '>=8'} @@ -3636,6 +3919,10 @@ packages: resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} engines: {node: '>= 0.4'} + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3704,6 +3991,10 @@ packages: workbox-window@7.4.1: resolution: {integrity: sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==} + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} + engines: {node: ^20.17.0 || >=22.9.0} + ws@7.5.13: resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} engines: {node: '>=8.3.0'} @@ -4494,12 +4785,29 @@ snapshots: dependencies: '@csstools/css-tokenizer': 4.0.0 + '@csstools/css-syntax-patches-for-csstree@1.1.14(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': optionalDependencies: css-tree: 3.2.1 '@csstools/css-tokenizer@4.0.0': {} + '@csstools/media-query-list-parser@5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/selector-resolve-nested@4.0.1(postcss-selector-parser@7.1.6)': + dependencies: + postcss-selector-parser: 7.1.6 + + '@csstools/selector-specificity@6.0.0(postcss-selector-parser@7.1.6)': + dependencies: + postcss-selector-parser: 7.1.6 + '@eslint-community/eslint-utils@4.10.1(eslint@10.10.0(jiti@2.7.0))': dependencies: eslint: 10.10.0(jiti@2.7.0) @@ -4927,6 +5235,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.63.2': optional: true + '@sindresorhus/merge-streams@4.0.0': {} + '@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1': dependencies: ejs: 3.1.10 @@ -5353,10 +5663,20 @@ snapshots: alien-signals@3.2.1: {} + ansi-regex@5.0.1: {} + + ansi-regex@6.3.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + ansi-styles@7.0.0: {} ansis@4.3.1: {} + argparse@2.0.1: {} + array-buffer-byte-length@1.0.2: dependencies: call-bound: 1.0.4 @@ -5385,6 +5705,8 @@ snapshots: '@babel/types': 7.29.8 ast-kit: 2.2.0 + astral-regex@2.0.0: {} + async-function@1.0.0: {} async@3.2.6: {} @@ -5496,6 +5818,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + callsites@3.1.0: {} + caniuse-lite@1.0.30001806: {} caniuse-lite@1.0.30001810: {} @@ -5510,6 +5834,14 @@ snapshots: dependencies: readdirp: 5.1.1 + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.10.0: {} + commander@14.0.3: {} commander@2.20.3: {} @@ -5533,6 +5865,15 @@ snapshots: dependencies: browserslist: 4.28.9 + cosmiconfig@9.0.2(typescript@6.0.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.3.2 + parse-json: 5.2.0 + optionalDependencies: + typescript: 6.0.3 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5541,6 +5882,8 @@ snapshots: crypto-random-string@2.0.0: {} + css-functions-list@3.3.3: {} + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -5608,6 +5951,24 @@ snapshots: detect-libc@2.1.2: {} + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5629,10 +5990,20 @@ snapshots: electron-to-chromium@1.5.427: {} + emoji-regex@8.0.0: {} + + entities@4.5.0: {} + entities@7.0.1: {} entities@8.0.0: {} + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + error-stack-parser-es@1.0.5: {} es-abstract-get@1.0.0: @@ -5849,6 +6220,8 @@ snapshots: fast-uri@3.1.7: {} + fastest-levenshtein@1.0.16: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -5928,6 +6301,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5977,17 +6352,41 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + globalthis@1.0.4: dependencies: define-properties: 1.2.1 gopd: 1.2.0 + globby@16.2.4: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.9 + is-path-inside: 4.0.0 + micromatch: 4.0.8 + slash: 5.1.0 + unicorn-magic: 0.4.0 + + globjoin@0.1.4: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} has-bigints@1.1.0: {} + has-flag@5.0.1: {} + has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 @@ -6022,12 +6421,28 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + html-tags@5.1.0: {} + + htmlparser2@9.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + idb@7.1.1: {} ignore@5.3.2: {} ignore@7.0.9: {} + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} ini@1.3.8: {} @@ -6044,6 +6459,8 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-arrayish@0.2.1: {} + is-async-function@2.1.1: dependencies: async-function: 1.0.0 @@ -6090,6 +6507,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -6123,6 +6542,8 @@ snapshots: is-obj@1.0.1: {} + is-path-inside@4.0.0: {} + is-potential-custom-element-name@1.0.1: {} is-regex@1.2.1: @@ -6202,6 +6623,12 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + jsdom@30.0.1: dependencies: '@asamuzakjp/css-color': 6.0.5 @@ -6230,6 +6657,8 @@ snapshots: jsesc@3.1.0: {} + json-parse-even-better-errors@2.3.1: {} + json-parse-even-better-errors@6.0.0: {} json-schema-traverse@0.4.1: {} @@ -6254,6 +6683,8 @@ snapshots: dependencies: '@keyv/serialize': 1.1.1 + kind-of@6.0.3: {} + kolorist@1.8.0: {} leaflet.heat@0.2.0: {} @@ -6320,6 +6751,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + lines-and-columns@1.2.4: {} + local-pkg@1.2.1: dependencies: mlly: 1.8.2 @@ -6332,6 +6765,8 @@ snapshots: lodash.debounce@4.0.8: {} + lodash.truncate@4.4.2: {} + lru-cache@11.5.2: {} lru-cache@5.1.1: @@ -6352,10 +6787,14 @@ snapshots: math-intrinsics@1.1.0: {} + mathml-tag-names@4.0.0: {} + mdn-data@2.27.1: {} memorystream@0.3.1: {} + meow@14.1.0: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -6402,6 +6841,8 @@ snapshots: dependencies: abbrev: 5.0.0 + normalize-path@3.0.0: {} + nostics@1.2.0: {} npm-normalize-package-bin@6.0.0: {} @@ -6495,6 +6936,17 @@ snapshots: package-json-from-dist@1.0.1: {} + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + parse5@8.0.1: dependencies: entities: 8.0.0 @@ -6554,11 +7006,24 @@ snapshots: possible-typed-array-names@1.1.0: {} + postcss-html@2.0.0(postcss@8.5.28): + dependencies: + htmlparser2: 9.1.0 + js-tokens: 9.0.1 + postcss: 8.5.28 + postcss-safe-parser: 7.1.0(postcss@8.5.28) + + postcss-safe-parser@7.1.0(postcss@8.5.28): + dependencies: + postcss: 8.5.28 + postcss-selector-parser@7.1.6: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 + postcss-value-parser@4.2.0: {} + postcss@8.5.28: dependencies: nanoid: 3.3.19 @@ -6645,6 +7110,8 @@ snapshots: requires-port@1.0.0: {} + resolve-from@4.0.0: {} + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -6814,6 +7281,14 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + slash@5.1.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + smob@1.6.2: {} source-map-js@1.2.1: {} @@ -6836,6 +7311,17 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + string.prototype.matchall@4.1.0: dependencies: call-bind: 1.0.9 @@ -6882,12 +7368,87 @@ snapshots: is-obj: 1.0.1 is-regexp: 1.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + strip-comments@2.0.1: {} + stylelint-config-recommended@18.0.0(stylelint@17.15.0(typescript@6.0.3)): + dependencies: + stylelint: 17.15.0(typescript@6.0.3) + + stylelint-config-standard@40.0.0(stylelint@17.15.0(typescript@6.0.3)): + dependencies: + stylelint: 17.15.0(typescript@6.0.3) + stylelint-config-recommended: 18.0.0(stylelint@17.15.0(typescript@6.0.3)) + + stylelint@17.15.0(typescript@6.0.3): + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-syntax-patches-for-csstree': 1.1.14(css-tree@3.2.1) + '@csstools/css-tokenizer': 4.0.0 + '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/selector-resolve-nested': 4.0.1(postcss-selector-parser@7.1.6) + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.6) + colord: 2.10.0 + cosmiconfig: 9.0.2(typescript@6.0.3) + css-functions-list: 3.3.3 + css-tree: 3.2.1 + debug: 4.4.3 + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.5 + global-modules: 2.0.0 + globby: 16.2.4 + globjoin: 0.1.4 + html-tags: 5.1.0 + ignore: 7.0.9 + import-meta-resolve: 4.2.0 + mathml-tag-names: 4.0.0 + meow: 14.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.28 + postcss-safe-parser: 7.1.0(postcss@8.5.28) + postcss-selector-parser: 7.1.6 + postcss-value-parser: 4.2.0 + string-width: 8.2.2 + supports-hyperlinks: 4.5.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 7.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@10.2.2: {} + + supports-hyperlinks@4.5.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + supports-preserve-symlinks-flag@1.0.0: {} + svg-tags@1.0.0: {} + symbol-tree@3.2.4: {} + table@6.9.0: + dependencies: + ajv: 8.20.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + temp-dir@2.0.0: {} tempy@0.6.0: @@ -7029,6 +7590,8 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} + unicorn-magic@0.4.0: {} + unique-string@2.0.0: dependencies: crypto-random-string: 2.0.0 @@ -7331,6 +7894,10 @@ snapshots: gopd: 1.2.0 has-tostringtag: 1.0.2 + which@1.3.1: + dependencies: + isexe: 2.0.0 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -7459,6 +8026,10 @@ snapshots: '@types/trusted-types': 2.0.7 workbox-core: 7.4.1 + write-file-atomic@7.0.1: + dependencies: + signal-exit: 4.1.0 + ws@7.5.13: {} wsl-utils@0.3.1: diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 72c71b63..fec6ed15 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -59,8 +59,8 @@ const { deleteAllNotifications, } = useNotifications() -const carModel = computed(() => vehicleStore.vehicles[0]?.model ?? null) -const vehicleId = computed(() => vehicleStore.vehicles[0]?.id ?? null) +const carModel = computed(() => vehicleStore.activeVehicle?.model ?? null) +const vehicleId = computed(() => vehicleStore.activeVehicle?.id ?? null) const availabilityToast = ref<'online' | 'offline' | null>(null) let toastTimer: ReturnType | null = null diff --git a/frontend/src/assets/login.css b/frontend/src/assets/login.css index 53255b0e..69c3c935 100644 --- a/frontend/src/assets/login.css +++ b/frontend/src/assets/login.css @@ -105,7 +105,7 @@ .login-field__input:-webkit-autofill, .login-field__input:-webkit-autofill:hover, .login-field__input:-webkit-autofill:focus { - -webkit-box-shadow: 0 0 0 1000px var(--color-surface-2) inset !important; + box-shadow: 0 0 0 1000px var(--color-surface-2) inset !important; -webkit-text-fill-color: var(--color-text) !important; caret-color: var(--color-text); border-color: var(--color-border) !important; @@ -132,8 +132,8 @@ display: flex; align-items: center; gap: 0.5rem; - background: rgba(239, 68, 68, 0.12); - border: 1px solid rgba(239, 68, 68, 0.3); + background: rgb(239 68 68 / 12%); + border: 1px solid rgb(239 68 68 / 30%); border-radius: 6px; color: var(--color-danger-text); font-size: 0.83rem; @@ -175,8 +175,8 @@ } .login-note--warning { - background: rgba(239, 68, 68, 0.12); - border-color: rgba(239, 68, 68, 0.3); + background: rgb(239 68 68 / 12%); + border-color: rgb(239 68 68 / 30%); color: var(--color-danger-text); } diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index d67e2ef5..2721d5a7 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -1,4 +1,4 @@ -@import '@vueform/multiselect/themes/default.css'; +@import url('@vueform/multiselect/themes/default.css'); :root { --color-bg: #0f1117; @@ -24,7 +24,7 @@ [data-theme='light'] { --color-bg: #f0f4f8; - --color-surface: #ffffff; + --color-surface: #fff; --color-surface-2: #e8edf3; --color-border: #cdd5df; --color-text: #1a202c; @@ -93,7 +93,7 @@ html, body, #app { height: 100%; - font-family: 'Inter', system-ui, sans-serif; + font-family: Inter, system-ui, sans-serif; background: var(--color-bg); color: var(--color-text); } @@ -169,7 +169,7 @@ body, display: none; position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.6); + background: rgb(0 0 0 / 60%); z-index: 1000; } @@ -265,9 +265,11 @@ body, .text-sm { font-size: 0.85rem; } + .text-success { color: var(--color-success); } + .text-danger { color: var(--color-danger); } @@ -277,18 +279,23 @@ body, .mt-2 { margin-top: 0.5rem; } + .mt-3 { margin-top: 1rem; } + .mt-4 { margin-top: 1.5rem; } + .mb-4 { margin-bottom: 1.5rem; } + .ms-2 { margin-left: 0.5rem; } + .text-xs { font-size: 0.75rem; } @@ -336,12 +343,15 @@ body, .status-card--success { border-left: 3px solid var(--color-success); } + .status-card--warning { border-left: 3px solid var(--color-warning); } + .status-card--danger { border-left: 3px solid var(--color-danger); } + .status-card--info { border-left: 3px solid var(--color-info); } @@ -415,7 +425,7 @@ body, position: fixed; inset: 0; z-index: 1100; - background: rgba(0, 0, 0, 0.6); + background: rgb(0 0 0 / 60%); display: flex; align-items: flex-end; justify-content: center; @@ -568,7 +578,7 @@ body, transform: translateY(100%); } -@media (min-width: 768px) { +@media (width >= 768px) { .detail-modal-backdrop { align-items: center; } @@ -629,29 +639,30 @@ body, } .road-edge { - stroke: rgba(255, 255, 255, 0.35); + stroke: rgb(255 255 255 / 35%); stroke-width: 1.5; } [data-theme='light'] .road-edge { - stroke: rgba(255, 255, 255, 0.7); + stroke: rgb(255 255 255 / 70%); } .road-center-dash { - stroke: rgba(255, 255, 255, 0.6); + stroke: rgb(255 255 255 / 60%); stroke-width: 2.5; stroke-dasharray: 30 15; animation: road-scroll 1s linear infinite; } [data-theme='light'] .road-center-dash { - stroke: rgba(255, 255, 255, 0.85); + stroke: rgb(255 255 255 / 85%); } @keyframes road-scroll { from { stroke-dashoffset: 0; } + to { stroke-dashoffset: -45; } @@ -693,7 +704,7 @@ body, flex-direction: column; } -@media (max-width: 767px) { +@media (width <= 767px) { .overview-row { flex-direction: column; } @@ -791,7 +802,7 @@ body, border-radius: 6px; color: var(--color-text); cursor: pointer; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); + box-shadow: 0 1px 4px rgb(0 0 0 / 20%); transition: background 0.15s; } @@ -902,43 +913,50 @@ body, .car-svg-body { fill: var(--car-primary, #f9b233); } + .car-svg-accent { fill: var(--car-secondary, #f39200); } + .car-svg-detail { fill: #3a3020; } + .car-svg-dark { fill: #1d1d1b; } + .car-svg-red { fill: #e6332a; } + .car-svg-highlight { fill: #ededed; opacity: 0.7; } + .car-svg-gloss { - fill: #ffffff; + fill: #fff; opacity: 0.18; } [data-theme='light'] .car-svg-detail { fill: #4a3c20; } + [data-theme='light'] .car-svg-dark { fill: #2d2d2d; } /* Live car marker (buildCarMarkerIcon in utils/mapCarIcon.ts) - used on both MapView and the - dashboard's location preview, so it lives here rather than in either view's own +