Compare commits

...
Author SHA1 Message Date
timothyandClaude Fable 5 73455aae28 fix(api): surface programs not filler in channel state, tighten queries
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m52s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial review fixes for the #97 endpoint:

- nowPlaying now resolves like the XMLTV guide: when the wall-clock item
  is pre/mid-roll filler, surface the guide group's program item (title +
  full program bounds) instead of the filler; a guide entry with no
  program item (e.g. fallback loop) reports null. Previously an ad break
  would retitle the channel and reset progress every 30 seconds.
- covering-item lookup now runs one cheap projected query per distinct
  playout offset instead of hydrating the full metadata include tree for
  a window widened by the largest offset across ALL channels (up to 24h);
  metadata is fetched in a second query scoped to the covering guide
  groups, time-bounded to stay safe against GuideGroup recycling (mod
  10000). Regression caught while testing: covering items are keyed by
  (source channel, lookup time) so a source channel no longer inherits
  its offset mirror's lookup result.
- GetChannelStatesForApi now carries Now explicitly (controller passes
  DateTime.UtcNow), making boundary semantics testable: new tests pin
  now == Start (playing) and now == Finish (not playing), plus mid-roll,
  filler-only, and mirror-source-row cases.
- ChannelNowPlayingResponseModel title is non-nullable in the schema
  (#nullable enable) — GetDisplayTitle always returns a string.
- GetState uses the typed-return controller convention; Playouts.Mapper
  reverted to internal (handler lives in the same assembly).
- Spec doc notes the OnDemand drift caveat and filler semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:53:21 +02:00
timothyandClaude Fable 5 e66f49e54a fix(api): make channel state NowPlaying nullable in DTO
An off-air channel has no current playout item, so the handler returns
null for NowPlaying; the DTO now declares that (file-scoped #nullable —
ErsatzTV.Core has NRT disabled project-wide). Regenerated OpenAPI and
web types: nowPlaying keeps its nullable oneOf schema (matching the
schema contract test) and channelNumber loses a spurious nullable
annotation left over from an earlier draft — the field is required.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:37:00 +02:00
timothy 07e9d9838d fix(api): resolve mirror channel now playing state 2026-07-02 20:02:01 +02:00
timothy f01ffc6254 test(api): enforce nullable channel now playing schema 2026-07-02 19:50:19 +02:00
timothy 6057bcea7d docs(api): document channel state endpoint 2026-07-02 19:45:53 +02:00
timothy 7b903d4bb4 feat(api): return channel runtime state 2026-07-02 19:37:14 +02:00
timothy 7c1a1d5672 feat(api): add channel state endpoint contract 2026-07-02 19:29:59 +02:00
timothy 1891498563 chore: ignore local worktrees 2026-07-02 19:24:06 +02:00
timothy 7484df1e5b docs(api): plan channel state endpoint 2026-07-02 19:20:50 +02:00
timothy 52bfdbcdce docs(api): specify channel state endpoint 2026-07-02 19:16:07 +02:00
timothy 6b3a698283 feat(api): add bulk channel operations
fixes #98
2026-07-02 19:07:35 +02:00
timothy 32c0d6a6d3 feat(api): enrich channel list response 2026-07-02 18:56:05 +02:00
timothy 97b6c4bee7 fix(web): harden spa foundation
refs #95
2026-07-02 18:36:56 +02:00
timothy 032b9d8116 fix(web): address app shell review findings
refs #94
2026-07-02 18:33:16 +02:00
timothy 6e6fd67fea feat(web): implement ChicoryTV dashboard
refs #83
2026-07-02 18:29:19 +02:00
timothy 5fb3bb2ebc feat(web): add ChicoryTV app shell
refs #82
2026-07-02 18:21:59 +02:00
timothy d1cc12e065 feat(web): add typed API client auth
refs #81
2026-07-02 08:23:19 +02:00
timothy e6df6ac10a feat(web): add ChicoryTV component primitives
refs #80
2026-07-02 08:09:09 +02:00
timothy e282bcecfa feat(frontend): wire ChicoryTV SPA themes
refs #79
2026-07-02 07:56:12 +02:00
timothy b183c6a154 feat(frontend): scaffold ChicoryTV SPA
refs #78
2026-07-02 07:37:00 +02:00
timothyandClaude Opus 4.8 c0b20a0ac5 docs: add auto-tuning screen + borrowed-concept enrichments to brief
Adds the Auto-tuning wizard as hero screen 5 (#69) and annotates the
Channels-list and Schedule-editor sections + prompts with the borrowed
enrichments: lineup health + resume badge (#72, #68), weighted
distribution / reroll / pad-to-boundary / seasonal (#70, #71, #77, #73).

Refs #59 #69

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:45:07 +02:00
timothyandClaude Opus 4.8 482396497e docs: link Create Channel backend gaps to epic #62 (#63-#68)
Refs #59 #62

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:02:48 +02:00
timothyandClaude Opus 4.8 4229717575 docs: add Create Channel workflow redesign + backend gaps
Replaces the inherited 4-editor channel-creation chain with a single
library-to-lineup builder: browse/search library with artwork, drag
shows into the lineup, friendly toggles (shuffle, always-on), one image
for logo+bug, technical settings via built-in/custom Channel Templates
with advanced override. Lists prerequisite backend gaps (composite
create endpoint, Channel Templates, library browse API, full image-type
support incl. transparent re-encode, unified logo/bug, always-on vs
on-demand) and a Claude Design prompt for the screen.

Refs #59

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:00:09 +02:00
timothyandClaude Opus 4.8 0826198d21 docs: add ChicoryTV UI redesign visual-direction brief (Pass 1)
Records the locked design direction for #59: React SPA on the REST API,
API-first (design pulls the API), Komodo-slick look, ChicoryTV dark +
chicory-blue brand. Includes the 4 hero screens with real domain
vocabulary and ready-to-paste Claude Design prompts.

Refs #59

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:58:12 +02:00
172 changed files with 24975 additions and 7 deletions
+31
View File
@@ -50,6 +50,37 @@ jobs:
- name: Restore
run: dotnet restore
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22.x'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install SPA dependencies
working-directory: web
run: npm ci
- name: Check generated SPA API client
working-directory: web
run: npm run check:api
- name: Lint SPA
working-directory: web
run: npm run lint
- name: Typecheck SPA
working-directory: web
run: npm run typecheck
- name: Test SPA
working-directory: web
run: npm test -- --run
- name: Build SPA
working-directory: web
run: npm run build
- name: Strip Scanner project ref (matches Docker build)
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
+4
View File
@@ -3,6 +3,7 @@
project.lock.json
.DS_Store
*.pyc
.worktrees/
# Claude Code
.mcp/
@@ -51,3 +52,6 @@ scripts/download-test-content.sh
docker-compose.override.yml
ErsatzTV/wwwroot/v2/
ErsatzTV/wwwroot/app/
web/dist/
web/node_modules/
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Channels;
public record BulkDeleteChannels(IReadOnlyList<int> ChannelIds) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,62 @@
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class BulkDeleteChannelsHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IFileSystem fileSystem,
ISearchTargets searchTargets)
: IRequestHandler<BulkDeleteChannels, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
BulkDeleteChannels request,
CancellationToken cancellationToken)
{
if (request.ChannelIds.Count == 0)
{
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
List<int> channelIds = request.ChannelIds.Distinct().ToList();
List<Channel> channels = await dbContext.Channels
.Where(c => channelIds.Contains(c.Id))
.ToListAsync(cancellationToken);
if (channels.Count != channelIds.Count)
{
var found = channels.Select(c => c.Id).ToHashSet();
int missingId = channelIds.First(id => !found.Contains(id));
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
}
dbContext.Channels.RemoveRange(channels);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
foreach (Channel channel in channels)
{
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
if (fileSystem.File.Exists(cacheFile))
{
fileSystem.File.Delete(cacheFile);
}
}
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return Right<BaseError, Unit>(Unit.Default);
}
}
@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Channels;
public record BulkMoveChannelsToGroup(IReadOnlyList<int> ChannelIds, string Group)
: IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,61 @@
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Channels.ChannelValidations;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class BulkMoveChannelsToGroupHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
ISearchTargets searchTargets)
: IRequestHandler<BulkMoveChannelsToGroup, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
BulkMoveChannelsToGroup request,
CancellationToken cancellationToken)
{
if (request.ChannelIds.Count == 0)
{
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
}
Validation<BaseError, string> groupValidation = ValidateGroup(request.Group);
if (groupValidation.IsFail)
{
return Left<BaseError, Unit>(groupValidation.FailToSeq().Head());
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
List<int> channelIds = request.ChannelIds.Distinct().ToList();
List<Channel> channels = await dbContext.Channels
.Where(c => channelIds.Contains(c.Id))
.ToListAsync(cancellationToken);
if (channels.Count != channelIds.Count)
{
var found = channels.Select(c => c.Id).ToHashSet();
int missingId = channelIds.First(id => !found.Contains(id));
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
}
foreach (Channel channel in channels)
{
channel.Group = request.Group;
}
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return Right<BaseError, Unit>(Unit.Default);
}
}
@@ -1,6 +1,8 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Channel = ErsatzTV.Core.Domain.Channel;
@@ -14,18 +16,41 @@ public class UpdateChannelNumbersHandler(
{
public async Task<Option<BaseError>> Handle(UpdateChannelNumbers request, CancellationToken cancellationToken)
{
Option<BaseError> validationError = ValidateRequest(request);
if (validationError.IsSome)
{
return validationError;
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
var numberUpdates = request.Channels.ToDictionary(c => c.Id, c => c.Number);
var channelIds = numberUpdates.Keys;
List<int> channelIds = numberUpdates.Keys.ToList();
List<Channel> channelsToUpdate = await dbContext.Channels
.Where(c => channelIds.Contains(c.Id))
.ToListAsync(cancellationToken);
if (channelsToUpdate.Count != channelIds.Count)
{
var found = channelsToUpdate.Select(c => c.Id).ToHashSet();
int missingId = channelIds.First(id => !found.Contains(id));
return new NotFoundError($"Channel {missingId} does not exist.");
}
List<string> requestedNumbers = numberUpdates.Values.ToList();
bool numberConflict = await dbContext.Channels
.AnyAsync(
c => requestedNumbers.Contains(c.Number) && !channelIds.Contains(c.Id),
cancellationToken);
if (numberConflict)
{
return BaseError.New("Channel number must be unique");
}
// give every channel a non-conflicting number
foreach (var channel in channelsToUpdate)
{
@@ -69,4 +94,32 @@ public class UpdateChannelNumbersHandler(
return BaseError.New("Failed to update channel numbers: " + ex.Message);
}
}
private static Option<BaseError> ValidateRequest(UpdateChannelNumbers request)
{
if (request.Channels.Count == 0)
{
return BaseError.New("At least one channel is required");
}
if (request.Channels.Select(c => c.Id).Distinct().Count() != request.Channels.Count)
{
return BaseError.New("Channel ids must be unique");
}
if (request.Channels.Select(c => c.Number).Distinct(StringComparer.Ordinal).Count() != request.Channels.Count)
{
return BaseError.New("Channel number must be unique");
}
foreach (ChannelSortViewModel channel in request.Channels)
{
if (!Regex.IsMatch(channel.Number, Channel.NumberValidator))
{
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
}
}
return Option<BaseError>.None;
}
}
+6 -1
View File
@@ -42,10 +42,15 @@ internal static class Mapper
new(
channel.Id,
channel.Number,
channel.SortNumber,
channel.Name,
channel.Group,
channel.Categories,
channel.FFmpegProfile.Name,
channel.PreferredAudioLanguageCode,
GetStreamingMode(channel));
GetStreamingMode(channel),
channel.IsEnabled,
channel.ShowInEpg);
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
new(resolution.Height, resolution.Width);
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
public record GetChannelStatesForApi(DateTime Now) : IRequest<List<ChannelStateResponseModel>>;
@@ -0,0 +1,151 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using PlayoutMapper = ErsatzTV.Application.Playouts.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelStatesForApiHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegSegmenterService ffmpegSegmenterService)
: IRequestHandler<GetChannelStatesForApi, List<ChannelStateResponseModel>>
{
// a guide entry (program + surrounding filler) never spans anywhere near a day; the time
// bound also protects against GuideGroup values recycling (mod 10000) elsewhere in a playout
private static readonly TimeSpan GuideEntryBound = TimeSpan.FromDays(1);
public async Task<List<ChannelStateResponseModel>> Handle(
GetChannelStatesForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<Channel> channels = await dbContext.Channels
.AsNoTracking()
.OrderBy(c => c.SortNumber)
.ThenBy(c => c.Number)
.ToListAsync(cancellationToken);
if (channels.Count == 0)
{
return [];
}
Dictionary<int, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> channelLookup = channels
.ToDictionary(
c => c.Id,
c =>
{
TimeSpan offset = c.PlayoutOffset ?? TimeSpan.Zero;
return (c.MirrorSourceChannelId ?? c.Id, offset, request.Now - offset);
});
// one covering-item query per distinct lookup time (i.e. per distinct playout offset,
// typically just one) with a cheap projection; metadata is hydrated in a second query
// for only the covering items' guide groups
var coveringBySourceAndTime = new Dictionary<(int SourceChannelId, DateTime LookupTime), CoveringItem>();
foreach (IGrouping<DateTime, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> timeGroup in
channelLookup.Values.GroupBy(v => v.LookupTime))
{
DateTime lookupTime = timeGroup.Key;
int[] sourceChannelIds = timeGroup.Map(v => v.SourceChannelId).Distinct().ToArray();
List<CoveringItem> covering = await dbContext.PlayoutItems
.AsNoTracking()
.Where(pi => sourceChannelIds.Contains(pi.Playout.ChannelId))
.Where(pi => pi.Start <= lookupTime && pi.Finish > lookupTime)
.Select(pi => new CoveringItem(pi.Id, pi.PlayoutId, pi.Playout.ChannelId, pi.GuideGroup, pi.Start))
.ToListAsync(cancellationToken);
foreach (CoveringItem item in covering.OrderBy(ci => ci.Start))
{
coveringBySourceAndTime.TryAdd((item.ChannelId, lookupTime), item);
}
}
Dictionary<(int PlayoutId, int GuideGroup), List<PlayoutItem>> itemsByGuideEntry = [];
if (coveringBySourceAndTime.Count > 0)
{
int[] playoutIds = coveringBySourceAndTime.Values.Map(ci => ci.PlayoutId).Distinct().ToArray();
int[] guideGroups = coveringBySourceAndTime.Values.Map(ci => ci.GuideGroup).Distinct().ToArray();
DateTime windowStart = channelLookup.Values.Min(v => v.LookupTime) - GuideEntryBound;
DateTime windowFinish = channelLookup.Values.Max(v => v.LookupTime) + GuideEntryBound;
List<PlayoutItem> guideEntryItems = await dbContext.PlayoutItems
.AsNoTracking()
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Episode).EpisodeMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Movie).MovieMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Song).SongMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Image).ImageMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
.Where(pi => playoutIds.Contains(pi.PlayoutId) && guideGroups.Contains(pi.GuideGroup))
.Where(pi => pi.Start <= windowFinish && pi.Finish > windowStart)
.ToListAsync(cancellationToken);
itemsByGuideEntry = guideEntryItems
.GroupBy(pi => (pi.PlayoutId, pi.GuideGroup))
.ToDictionary(g => g.Key, g => g.OrderBy(pi => pi.Start).ToList());
}
return channels
.Map(channel =>
{
(int sourceChannelId, TimeSpan offset, DateTime lookupTime) = channelLookup[channel.Id];
ChannelNowPlayingResponseModel nowPlaying = null;
if (coveringBySourceAndTime.TryGetValue((sourceChannelId, lookupTime), out CoveringItem covering) &&
itemsByGuideEntry.TryGetValue(
(covering.PlayoutId, covering.GuideGroup),
out List<PlayoutItem> guideEntry))
{
// like the XMLTV guide, surface the program rather than its filler: use the
// covering item itself when it is a program part, otherwise the guide entry's
// first program item; an entry with no program item (e.g. offline fallback
// filler) stays null
PlayoutItem coveringItem = guideEntry.Find(pi => pi.Id == covering.Id);
PlayoutItem displayItem = coveringItem?.FillerKind == FillerKind.None
? coveringItem
: guideEntry.Find(pi => pi.FillerKind == FillerKind.None);
if (displayItem is not null)
{
DateTime start = guideEntry[0].Start;
DateTime finish = displayItem.GuideFinish ?? guideEntry.Max(pi => pi.Finish);
nowPlaying = new ChannelNowPlayingResponseModel(
PlayoutMapper.GetDisplayTitle(displayItem.MediaItem, Optional(displayItem.ChapterTitle)),
new DateTimeOffset(start + offset, TimeSpan.Zero),
new DateTimeOffset(finish + offset, TimeSpan.Zero));
}
}
return new ChannelStateResponseModel(
channel.Id,
channel.Number,
ffmpegSegmenterService.IsActive(channel.Number),
nowPlaying);
})
.ToList();
}
private sealed record CoveringItem(int Id, int PlayoutId, int ChannelId, int GuideGroup, DateTime Start);
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api.Channels;
#nullable enable
public record ChannelNowPlayingResponseModel(
string Title,
DateTimeOffset StartUtc,
DateTimeOffset FinishUtc);
@@ -5,8 +5,13 @@ namespace ErsatzTV.Core.Api.Channels;
public record ChannelResponseModel(
int Id,
string Number,
double SortNumber,
string Name,
string Group,
string Categories,
[property: JsonProperty("ffmpegProfile")]
string FFmpegProfile,
string Language,
string StreamingMode);
string StreamingMode,
bool IsEnabled,
bool ShowInEpg);
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Api.Channels;
#nullable enable
public record ChannelStateResponseModel(
int ChannelId,
string ChannelNumber,
bool OnAir,
ChannelNowPlayingResponseModel? NowPlaying);
@@ -0,0 +1,57 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class BulkDeleteChannelsHandlerTests : ChannelHandlerTestBase
{
private readonly MockFileSystem _fileSystem = new();
private BulkDeleteChannelsHandler MakeHandler() => new(Db.Factory, Worker, _fileSystem, SearchTargets);
[Test]
public async Task Should_Delete_All_Channels()
{
await SeedChannel(1, "5");
await SeedChannel(2, "6");
Either<BaseError, Unit> result =
await MakeHandler().Handle(new BulkDeleteChannels([1, 2]), CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
int count = await context.Channels.CountAsync();
count.ShouldBe(0);
SearchTargets.Received(1).SearchTargetsChanged();
}
[Test]
public async Task Should_Return_NotFound_And_Not_Delete_When_Any_Channel_Is_Missing()
{
await SeedChannel(1, "5");
Either<BaseError, Unit> result =
await MakeHandler().Handle(new BulkDeleteChannels([1, 99]), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldBeOfType<NotFoundError>();
await using TvContext context = Db.CreateContext();
bool exists = await context.Channels.AnyAsync(c => c.Id == 1);
exists.ShouldBeTrue();
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
}
@@ -0,0 +1,74 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class BulkMoveChannelsToGroupHandlerTests : ChannelHandlerTestBase
{
private BulkMoveChannelsToGroupHandler MakeHandler() => new(Db.Factory, Worker, SearchTargets);
[Test]
public async Task Should_Move_All_Channels_To_Group()
{
await SeedChannel(1, "5");
await SeedChannel(2, "6");
Either<BaseError, Unit> result =
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1, 2], "Movies"), CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
List<string> groups = await context.Channels
.OrderBy(c => c.Id)
.Select(c => c.Group)
.ToListAsync();
groups.ShouldBe(["Movies", "Movies"]);
SearchTargets.Received(1).SearchTargetsChanged();
}
[Test]
public async Task Should_Return_NotFound_And_Not_Move_When_Any_Channel_Is_Missing()
{
await SeedChannel(1, "5", group: "Original");
Either<BaseError, Unit> result =
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1, 99], "Movies"), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldBeOfType<NotFoundError>();
await using TvContext context = Db.CreateContext();
string group = await context.Channels.Where(c => c.Id == 1).Select(c => c.Group).SingleAsync();
group.ShouldBe("Original");
}
[Test]
public async Task Should_Reject_Empty_Group()
{
await SeedChannel(1, "5", group: "Original");
Either<BaseError, Unit> result =
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1], ""), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("group");
await using TvContext context = Db.CreateContext();
string group = await context.Channels.Where(c => c.Id == 1).Select(c => c.Group).SingleAsync();
group.ShouldBe("Original");
}
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
}
@@ -0,0 +1,53 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class GetAllChannelsForApiHandlerTests
{
[Test]
public async Task Should_Project_Management_Table_Fields()
{
IChannelRepository repository = Substitute.For<IChannelRepository>();
repository.GetAll(Arg.Any<CancellationToken>())
.Returns([
new Channel(Guid.NewGuid())
{
Id = 7,
Number = "7.1",
SortNumber = 7.1,
Name = "Retro Cartoons",
Group = "Kids",
Categories = "animation",
FFmpegProfile = new FFmpegProfile { Name = "HLS 720p" },
PreferredAudioLanguageCode = "eng",
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
IsEnabled = false,
ShowInEpg = false
}
]);
var handler = new GetAllChannelsForApiHandler(repository);
List<ChannelResponseModel> result = await handler.Handle(new GetAllChannelsForApi(), CancellationToken.None);
ChannelResponseModel channel = result.ShouldHaveSingleItem();
channel.Id.ShouldBe(7);
channel.Number.ShouldBe("7.1");
channel.SortNumber.ShouldBe(7.1);
channel.Name.ShouldBe("Retro Cartoons");
channel.Group.ShouldBe("Kids");
channel.Categories.ShouldBe("animation");
channel.FFmpegProfile.ShouldBe("HLS 720p");
channel.Language.ShouldBe("eng");
channel.StreamingMode.ShouldBe("HLS Segmenter");
channel.IsEnabled.ShouldBeFalse();
channel.ShowInEpg.ShouldBeFalse();
}
}
@@ -0,0 +1,329 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class GetChannelStatesForApiHandlerTests
{
private static readonly DateTime Now = new(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc);
private InMemoryTvContext _db = null!;
private IFFmpegSegmenterService _segmenter = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_segmenter = Substitute.For<IFFmpegSegmenterService>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Handle_Should_Return_OnAir_And_Current_NowPlaying()
{
DateTime start = Now.AddMinutes(-10);
DateTime finish = Now.AddMinutes(20);
await SeedChannelWithMovie(start, finish);
_segmenter.IsActive("7.1").Returns(true);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
state.ChannelId.ShouldBe(7);
state.ChannelNumber.ShouldBe("7.1");
state.OnAir.ShouldBeTrue();
state.NowPlaying.ShouldNotBeNull();
state.NowPlaying.Title.ShouldBe("Retro Cartoons");
state.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(start, TimeSpan.Zero));
state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(finish, TimeSpan.Zero));
}
[Test]
public async Task Handle_Should_Match_Item_When_Now_Equals_Start()
{
await SeedChannelWithMovie(Now, Now.AddMinutes(30));
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
result.ShouldHaveSingleItem().NowPlaying.ShouldNotBeNull();
}
[Test]
public async Task Handle_Should_Not_Match_Item_When_Now_Equals_Finish()
{
await SeedChannelWithMovie(Now.AddMinutes(-30), Now);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
result.ShouldHaveSingleItem().NowPlaying.ShouldBeNull();
}
[Test]
public async Task Handle_Should_Return_Null_NowPlaying_When_No_Current_Item()
{
await using TvContext context = _db.CreateContext();
context.Channels.Add(MakeChannel(8, "8"));
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
state.OnAir.ShouldBeFalse();
state.NowPlaying.ShouldBeNull();
}
[Test]
public async Task Handle_Should_Surface_Program_Not_Filler_During_MidRoll_Break()
{
// program part 1 / mid-roll filler / program part 2, all in one guide group
await using TvContext context = _db.CreateContext();
Channel channel = MakeChannel(12, "12");
var movie = new Movie
{
Id = 120,
MovieMetadata = [new MovieMetadata { Title = "Feature Presentation" }]
};
var filler = new OtherVideo
{
Id = 121,
OtherVideoMetadata = [new OtherVideoMetadata { Title = "Some Bumper" }]
};
var playout = new Playout { Id = 122, Channel = channel, ChannelId = channel.Id, Items = [] };
PlayoutItem MakeItem(int id, MediaItem mediaItem, DateTime start, DateTime finish, FillerKind fillerKind) =>
new()
{
Id = id,
MediaItem = mediaItem,
MediaItemId = mediaItem.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = start,
Finish = finish,
FillerKind = fillerKind,
GuideGroup = 1,
ChapterTitle = string.Empty
};
context.Channels.Add(channel);
context.Movies.Add(movie);
context.OtherVideos.Add(filler);
context.Playouts.Add(playout);
context.PlayoutItems.AddRange(
MakeItem(123, movie, Now.AddMinutes(-20), Now.AddMinutes(-2), FillerKind.None),
MakeItem(124, filler, Now.AddMinutes(-2), Now.AddMinutes(2), FillerKind.MidRoll),
MakeItem(125, movie, Now.AddMinutes(2), Now.AddMinutes(40), FillerKind.None));
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
state.NowPlaying.ShouldNotBeNull();
state.NowPlaying.Title.ShouldBe("Feature Presentation");
state.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(Now.AddMinutes(-20), TimeSpan.Zero));
state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(Now.AddMinutes(40), TimeSpan.Zero));
}
[Test]
public async Task Handle_Should_Return_Null_NowPlaying_For_Guide_Entry_With_Only_Filler()
{
await using TvContext context = _db.CreateContext();
Channel channel = MakeChannel(13, "13");
var filler = new OtherVideo
{
Id = 130,
OtherVideoMetadata = [new OtherVideoMetadata { Title = "Offline Loop" }]
};
var playout = new Playout { Id = 131, Channel = channel, ChannelId = channel.Id, Items = [] };
var item = new PlayoutItem
{
Id = 132,
MediaItem = filler,
MediaItemId = filler.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.AddMinutes(-5),
Finish = Now.AddMinutes(5),
FillerKind = FillerKind.Fallback,
GuideGroup = 1,
ChapterTitle = string.Empty
};
context.Channels.Add(channel);
context.OtherVideos.Add(filler);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
result.ShouldHaveSingleItem().NowPlaying.ShouldBeNull();
}
[Test]
public async Task Handle_Should_Map_Remote_Stream_NowPlaying_Title()
{
DateTime start = Now.AddMinutes(-10);
DateTime finish = Now.AddMinutes(20);
await using TvContext context = _db.CreateContext();
Channel channel = MakeChannel(9, "9");
var remoteStream = new RemoteStream
{
Id = 90,
RemoteStreamMetadata = [new RemoteStreamMetadata { Title = "Live Source" }]
};
var playout = new Playout { Id = 91, Channel = channel, ChannelId = channel.Id, Items = [] };
var item = new PlayoutItem
{
Id = 92,
MediaItem = remoteStream,
MediaItemId = remoteStream.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = start,
Finish = finish,
ChapterTitle = string.Empty
};
context.Channels.Add(channel);
context.RemoteStreams.Add(remoteStream);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
ChannelStateResponseModel state =
(await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None))
.ShouldHaveSingleItem();
state.NowPlaying.ShouldNotBeNull();
state.NowPlaying.Title.ShouldBe("Live Source");
}
[Test]
public async Task Handle_Should_Resolve_Mirror_Channel_NowPlaying_From_Source_With_Offset()
{
TimeSpan offset = TimeSpan.FromHours(1);
DateTime sourceStart = Now.AddMinutes(-70);
DateTime sourceFinish = Now.AddMinutes(-40);
await using TvContext context = _db.CreateContext();
Channel source = MakeChannel(10, "10");
Channel mirror = MakeChannel(11, "11");
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
mirror.MirrorSourceChannelId = source.Id;
mirror.PlayoutOffset = offset;
var movie = new Movie
{
Id = 110,
MovieMetadata = [new MovieMetadata { Title = "Offset Feature" }]
};
var playout = new Playout { Id = 111, Channel = source, ChannelId = source.Id, Items = [] };
var item = new PlayoutItem
{
Id = 112,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = sourceStart,
Finish = sourceFinish,
ChapterTitle = string.Empty
};
context.Channels.AddRange(source, mirror);
context.Movies.Add(movie);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
ChannelStateResponseModel mirrorState = result.Single(s => s.ChannelId == mirror.Id);
mirrorState.NowPlaying.ShouldNotBeNull();
mirrorState.NowPlaying.Title.ShouldBe("Offset Feature");
mirrorState.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(sourceStart + offset, TimeSpan.Zero));
mirrorState.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(sourceFinish + offset, TimeSpan.Zero));
// the source's own item is 40+ minutes in the past with no offset, so its own row is off-air
ChannelStateResponseModel sourceState = result.Single(s => s.ChannelId == source.Id);
sourceState.NowPlaying.ShouldBeNull();
}
private async Task SeedChannelWithMovie(DateTime start, DateTime finish)
{
await using TvContext context = _db.CreateContext();
Channel channel = MakeChannel(7, "7.1");
var movie = new Movie
{
Id = 10,
MovieMetadata = [new MovieMetadata { Title = "Retro Cartoons" }]
};
var playout = new Playout
{
Id = 20,
Channel = channel,
ChannelId = channel.Id,
Items = []
};
var item = new PlayoutItem
{
Id = 30,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = start,
Finish = finish,
ChapterTitle = string.Empty
};
context.Channels.Add(channel);
context.Movies.Add(movie);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
}
private static Channel MakeChannel(int id, string number) =>
new(Guid.NewGuid())
{
Id = id,
Number = number,
SortNumber = id,
Name = $"Channel {number}",
Group = "Test",
Categories = string.Empty,
PreferredAudioLanguageCode = string.Empty
};
}
@@ -0,0 +1,111 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class UpdateChannelNumbersHandlerTests : ChannelHandlerTestBase
{
private UpdateChannelNumbersHandler MakeHandler() => new(Db.Factory, Worker);
[Test]
public async Task Should_Renumber_All_Channels()
{
await SeedChannel(1, "5");
await SeedChannel(2, "6");
Option<BaseError> result = await MakeHandler().Handle(
new UpdateChannelNumbers(
[
new ChannelSortViewModel { Id = 1, Number = "10" },
new ChannelSortViewModel { Id = 2, Number = "11.1" }
]),
CancellationToken.None);
result.IsNone.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
var channels = await context.Channels
.OrderBy(c => c.Id)
.Select(c => new { c.Number, c.SortNumber })
.ToListAsync();
channels[0].Number.ShouldBe("10");
channels[0].SortNumber.ShouldBe(10);
channels[1].Number.ShouldBe("11.1");
channels[1].SortNumber.ShouldBe(11.1);
}
[Test]
public async Task Should_Return_NotFound_And_Not_Renumber_When_Any_Channel_Is_Missing()
{
await SeedChannel(1, "5");
Option<BaseError> result = await MakeHandler().Handle(
new UpdateChannelNumbers(
[
new ChannelSortViewModel { Id = 1, Number = "10" },
new ChannelSortViewModel { Id = 99, Number = "11" }
]),
CancellationToken.None);
BaseError error = ErrorOf(result);
error.ShouldBeOfType<NotFoundError>();
await using TvContext context = Db.CreateContext();
string number = await context.Channels.Where(c => c.Id == 1).Select(c => c.Number).SingleAsync();
number.ShouldBe("5");
}
[Test]
public async Task Should_Reject_Duplicate_Numbers_And_Not_Renumber()
{
await SeedChannel(1, "5");
await SeedChannel(2, "6");
Option<BaseError> result = await MakeHandler().Handle(
new UpdateChannelNumbers(
[
new ChannelSortViewModel { Id = 1, Number = "10" },
new ChannelSortViewModel { Id = 2, Number = "10" }
]),
CancellationToken.None);
BaseError error = ErrorOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("unique");
await using TvContext context = Db.CreateContext();
List<string> numbers = await context.Channels.OrderBy(c => c.Id).Select(c => c.Number).ToListAsync();
numbers.ShouldBe(["5", "6"]);
}
[Test]
public async Task Should_Reject_Invalid_Number_And_Not_Renumber()
{
await SeedChannel(1, "5");
Option<BaseError> result = await MakeHandler().Handle(
new UpdateChannelNumbers([new ChannelSortViewModel { Id = 1, Number = "10.123" }]),
CancellationToken.None);
BaseError error = ErrorOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("Invalid channel number");
await using TvContext context = Db.CreateContext();
string number = await context.Channels.Where(c => c.Id == 1).Select(c => c.Number).SingleAsync();
number.ShouldBe("5");
}
private static BaseError ErrorOf(Option<BaseError> result) =>
result.Match(
Some: error => error,
None: () => throw new AssertionException("Expected an error"));
}
@@ -17,6 +17,12 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkRenumber), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkRenumber), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkMoveToGroup), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkMoveToGroup), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)]
[TestCase(typeof(CollectionController), nameof(CollectionController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(CollectionController), nameof(CollectionController.Create), StatusCodes.Status404NotFound)]
@@ -6,6 +6,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using LanguageExt;
@@ -34,6 +35,25 @@ public class ChannelControllerTests
_controller = new ChannelController(writer, _mediator);
}
[Test]
public async Task GetState_Should_Return_200_With_Channel_State()
{
var state = new ChannelStateResponseModel(
7,
"7.1",
true,
new ChannelNowPlayingResponseModel(
"Retro Cartoons",
new DateTimeOffset(2026, 7, 2, 17, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 17, 30, 0, TimeSpan.Zero)));
_mediator.Send(Arg.Any<GetChannelStatesForApi>(), Arg.Any<CancellationToken>())
.Returns([state]);
List<ChannelStateResponseModel> result = await _controller.GetState(CancellationToken.None);
result.ShouldBe(new List<ChannelStateResponseModel> { state });
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
@@ -125,6 +145,95 @@ public class ChannelControllerTests
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task BulkRenumber_Should_Return_204_And_Map_Request()
{
_mediator.Send(Arg.Any<UpdateChannelNumbers>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.None);
IActionResult result = await _controller.BulkRenumber(
new BulkRenumberChannelsRequest([new BulkRenumberChannelRequest(1, "10")]),
CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<UpdateChannelNumbers>(
c => c.Channels.Count == 1 && c.Channels[0].Id == 1 && c.Channels[0].Number == "10"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task BulkRenumber_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateChannelNumbers>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.Some(BaseError.New("bad")));
IActionResult result = await _controller.BulkRenumber(
new BulkRenumberChannelsRequest([new BulkRenumberChannelRequest(1, "bad")]),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task BulkMoveToGroup_Should_Return_204_And_Map_Request()
{
_mediator.Send(Arg.Any<BulkMoveChannelsToGroup>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.BulkMoveToGroup(
new BulkMoveChannelsToGroupRequest([1, 2], "Movies"),
CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<BulkMoveChannelsToGroup>(
c => c.ChannelIds.SequenceEqual(new[] { 1, 2 }) && c.Group == "Movies"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task BulkMoveToGroup_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<BulkMoveChannelsToGroup>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.BulkMoveToGroup(
new BulkMoveChannelsToGroupRequest([99], "Movies"),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task BulkDelete_Should_Return_204_And_Map_Request()
{
_mediator.Send(Arg.Any<BulkDeleteChannels>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.BulkDelete(
new BulkDeleteChannelsRequest([1, 2]),
CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<BulkDeleteChannels>(c => c.ChannelIds.SequenceEqual(new[] { 1, 2 })),
Arg.Any<CancellationToken>());
}
[Test]
public async Task BulkDelete_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<BulkDeleteChannels>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
IActionResult result = await _controller.BulkDelete(
new BulkDeleteChannelsRequest([99]),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
@@ -23,6 +23,82 @@ public class OpenApiErrorResponseContractTests
.ShouldContain("One");
}
[Test]
public void Static_OpenApi_Should_Document_Channel_List_Management_Fields()
{
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument()));
JsonElement schema = document.RootElement
.GetProperty("components")
.GetProperty("schemas")
.GetProperty("ChannelResponseModel")
.GetProperty("properties");
schema.TryGetProperty("group", out _).ShouldBeTrue();
schema.TryGetProperty("isEnabled", out _).ShouldBeTrue();
schema.TryGetProperty("showInEpg", out _).ShouldBeTrue();
schema.TryGetProperty("sortNumber", out _).ShouldBeTrue();
schema.TryGetProperty("categories", out _).ShouldBeTrue();
}
[Test]
public void Static_OpenApi_Should_Document_Channel_State_Response()
{
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument()));
JsonElement getState = document.RootElement
.GetProperty("paths")
.GetProperty("/api/channels/state")
.GetProperty("get");
JsonElement schema = getState
.GetProperty("responses")
.GetProperty("200")
.GetProperty("content")
.GetProperty("application/json")
.GetProperty("schema");
schema.GetProperty("type").GetString().ShouldBe("array");
schema.GetProperty("items").GetProperty("$ref").GetString()
.ShouldBe("#/components/schemas/ChannelStateResponseModel");
JsonElement stateProperties = document.RootElement
.GetProperty("components")
.GetProperty("schemas")
.GetProperty("ChannelStateResponseModel")
.GetProperty("properties");
stateProperties.TryGetProperty("channelId", out _).ShouldBeTrue();
stateProperties.TryGetProperty("channelNumber", out _).ShouldBeTrue();
stateProperties.TryGetProperty("onAir", out _).ShouldBeTrue();
stateProperties.TryGetProperty("nowPlaying", out JsonElement nowPlaying).ShouldBeTrue();
JsonElement nowPlayingOneOf = nowPlaying.GetProperty("oneOf");
nowPlayingOneOf.EnumerateArray().Count().ShouldBe(2);
nowPlayingOneOf.EnumerateArray()
.Any(s => s.TryGetProperty("type", out JsonElement type) && type.GetString() == "null")
.ShouldBeTrue();
nowPlayingOneOf.EnumerateArray()
.Any(s => s.TryGetProperty("$ref", out JsonElement schemaRef) &&
schemaRef.GetString() == "#/components/schemas/ChannelNowPlayingResponseModel")
.ShouldBeTrue();
JsonElement nowPlayingProperties = document.RootElement
.GetProperty("components")
.GetProperty("schemas")
.GetProperty("ChannelNowPlayingResponseModel")
.GetProperty("properties");
nowPlayingProperties.TryGetProperty("title", out _).ShouldBeTrue();
nowPlayingProperties.TryGetProperty("startUtc", out JsonElement startUtc).ShouldBeTrue();
nowPlayingProperties.TryGetProperty("finishUtc", out JsonElement finishUtc).ShouldBeTrue();
startUtc.GetProperty("type").GetString().ShouldBe("string");
startUtc.GetProperty("format").GetString().ShouldBe("date-time");
finishUtc.GetProperty("type").GetString().ShouldBe("string");
finishUtc.GetProperty("format").GetString().ShouldBe("date-time");
}
[TestCase("/api/channels/{id}", "get", "404")]
[TestCase("/api/channels", "post", "404")]
[TestCase("/api/channels", "post", "422")]
@@ -30,6 +106,12 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/channels/{id}", "put", "422")]
[TestCase("/api/channels/{id}", "delete", "404")]
[TestCase("/api/channels/{id}", "delete", "422")]
[TestCase("/api/channels/bulk/renumber", "post", "404")]
[TestCase("/api/channels/bulk/renumber", "post", "422")]
[TestCase("/api/channels/bulk/group", "post", "404")]
[TestCase("/api/channels/bulk/group", "post", "422")]
[TestCase("/api/channels/bulk/delete", "post", "404")]
[TestCase("/api/channels/bulk/delete", "post", "422")]
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")]
[TestCase("/api/collections/{id}", "get", "404")]
[TestCase("/api/collections", "post", "404")]
+45
View File
@@ -0,0 +1,45 @@
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests;
[TestFixture]
public class StartupSpaHostingTests
{
private static readonly string StartupSource = File.ReadAllText(FindStartupPath());
[Test]
public void Startup_Should_Mount_ChicoryTv_Spa_At_App_Path()
{
StartupSource.ShouldContain("ctx.Request.Path.StartsWithSegments(\"/app\")");
StartupSource.ShouldContain("MapWhen(");
StartupSource.ShouldContain("SpaStaticFileRoot");
StartupSource.ShouldContain("RequestPath = \"/app\"");
StartupSource.ShouldContain("appIndexFile");
}
[Test]
public void Startup_Should_Not_Send_App_Routes_To_Blazor_Fallback()
{
StartupSource.ShouldContain("!IsSpaPath(ctx.Request.Path)");
StartupSource.ShouldContain("bool IsSpaPath(PathString path)");
}
private static string FindStartupPath()
{
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
while (directory is not null)
{
string candidate = Path.Combine(directory.FullName, "ErsatzTV", "Startup.cs");
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException("Could not find ErsatzTV/Startup.cs");
}
}
@@ -35,7 +35,12 @@ public abstract class ChannelHandlerTestBase
await context.SaveChangesAsync();
}
protected async Task SeedChannel(int id, string number, string name = "Test", int ffmpegProfileId = 1)
protected async Task SeedChannel(
int id,
string number,
string name = "Test",
int ffmpegProfileId = 1,
string group = "ErsatzTV")
{
await using TvContext context = Db.CreateContext();
context.Channels.Add(
@@ -44,7 +49,7 @@ public abstract class ChannelHandlerTestBase
Id = id,
Number = number,
Name = name,
Group = "ErsatzTV",
Group = group,
Categories = string.Empty,
FFmpegProfileId = ffmpegProfileId,
StreamSelector = string.Empty,
@@ -21,6 +21,13 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
[EndpointGroupName("general")]
public async Task<List<ChannelResponseModel>> GetAll() => await mediator.Send(new GetAllChannelsForApi());
[HttpGet("/api/channels/state")]
[Tags("Channels")]
[EndpointSummary("Get channel runtime state")]
[EndpointGroupName("general")]
public async Task<List<ChannelStateResponseModel>> GetState(CancellationToken cancellationToken) =>
await mediator.Send(new GetChannelStatesForApi(DateTime.UtcNow), cancellationToken);
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
[Tags("Channels")]
[EndpointSummary("Get a channel by id")]
@@ -87,6 +94,53 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
return result.ToDeletedResult();
}
[HttpPost("/api/channels/bulk/renumber")]
[Tags("Channels")]
[EndpointSummary("Renumber channels")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> BulkRenumber(
[Required] [FromBody] BulkRenumberChannelsRequest request,
CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.Match<IActionResult>(
Some: error => error.ToErrorResult(),
None: () => new NoContentResult());
}
[HttpPost("/api/channels/bulk/group")]
[Tags("Channels")]
[EndpointSummary("Move channels to a group")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> BulkMoveToGroup(
[Required] [FromBody] BulkMoveChannelsToGroupRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToDeletedResult();
}
[HttpPost("/api/channels/bulk/delete")]
[Tags("Channels")]
[EndpointSummary("Delete channels")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> BulkDelete(
[Required] [FromBody] BulkDeleteChannelsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToDeletedResult();
}
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
[Tags("Channels")]
[EndpointSummary("Reset channel playout")]
@@ -0,0 +1,21 @@
using ErsatzTV.Application.Channels;
namespace ErsatzTV.Controllers.Api.Requests;
public record BulkRenumberChannelRequest(int Id, string Number);
public record BulkRenumberChannelsRequest(List<BulkRenumberChannelRequest> Channels)
{
public UpdateChannelNumbers ToCommand() =>
new(Channels.Select(c => new ChannelSortViewModel { Id = c.Id, Number = c.Number }).ToList());
}
public record BulkMoveChannelsToGroupRequest(List<int> ChannelIds, string Group)
{
public BulkMoveChannelsToGroup ToCommand() => new(ChannelIds, Group);
}
public record BulkDeleteChannelsRequest(List<int> ChannelIds)
{
public BulkDeleteChannels ToCommand() => new(ChannelIds);
}
+46 -1
View File
@@ -681,7 +681,42 @@ public class Startup
});
app.MapWhen(
ctx => !IsIptvPath(ctx.Request.Path),
ctx => ctx.Request.Path.StartsWithSegments("/app"),
spa =>
{
string spaStaticFileRoot = SpaStaticFileRoot();
IFileProvider spaFileProvider = Directory.Exists(spaStaticFileRoot)
? new PhysicalFileProvider(spaStaticFileRoot)
: new NullFileProvider();
spa.UseStaticFiles(
new StaticFileOptions
{
FileProvider = spaFileProvider,
RequestPath = "/app"
});
spa.Run(
async context =>
{
Microsoft.Extensions.FileProviders.IFileInfo appIndexFile =
spaFileProvider.GetFileInfo("index.html");
if (!appIndexFile.Exists)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
context.Response.ContentType = "text/html; charset=utf-8";
context.Response.ContentLength = appIndexFile.Length;
await using Stream stream = appIndexFile.CreateReadStream();
await stream.CopyToAsync(context.Response.Body);
});
});
app.MapWhen(
ctx => !IsIptvPath(ctx.Request.Path) && !IsSpaPath(ctx.Request.Path),
blazor =>
{
blazor.UseRouting();
@@ -736,6 +771,16 @@ public class Startup
path.StartsWithSegments("/lineup.json") ||
path.StartsWithSegments("/lineup_status.json");
}
bool IsSpaPath(PathString path) => path.StartsWithSegments("/app");
string SpaStaticFileRoot()
{
string webRootPath = CurrentEnvironment.WebRootPath ??
Path.Combine(CurrentEnvironment.ContentRootPath, "wwwroot");
return Path.Combine(webRootPath, "app");
}
}
private static void CustomServices(IServiceCollection services)
+432 -1
View File
@@ -136,6 +136,45 @@
}
}
},
"/api/channels/state": {
"get": {
"tags": [
"Channels"
],
"summary": "Get channel runtime state",
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ChannelStateResponseModel"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ChannelStateResponseModel"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ChannelStateResponseModel"
}
}
}
}
}
}
}
},
"/api/channels/{id}": {
"get": {
"tags": [
@@ -364,6 +403,240 @@
}
}
},
"/api/channels/bulk/renumber": {
"post": {
"tags": [
"Channels"
],
"summary": "Renumber channels",
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/BulkRenumberChannelsRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkRenumberChannelsRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/BulkRenumberChannelsRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/BulkRenumberChannelsRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/channels/bulk/group": {
"post": {
"tags": [
"Channels"
],
"summary": "Move channels to a group",
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/BulkMoveChannelsToGroupRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkMoveChannelsToGroupRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/BulkMoveChannelsToGroupRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/BulkMoveChannelsToGroupRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/channels/bulk/delete": {
"post": {
"tags": [
"Channels"
],
"summary": "Delete channels",
"requestBody": {
"content": {
"application/json-patch+json": {
"schema": {
"$ref": "#/components/schemas/BulkDeleteChannelsRequest"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/BulkDeleteChannelsRequest"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/BulkDeleteChannelsRequest"
}
},
"application/*+json": {
"schema": {
"$ref": "#/components/schemas/BulkDeleteChannelsRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"404": {
"description": "Not Found",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Unprocessable Entity",
"content": {
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/channels/{channelNumber}/playout/reset": {
"post": {
"tags": [
@@ -3060,6 +3333,85 @@
}
}
},
"BulkDeleteChannelsRequest": {
"required": [
"channelIds"
],
"type": "object",
"properties": {
"channelIds": {
"type": [
"null",
"array"
],
"items": {
"type": "integer",
"format": "int32"
}
}
}
},
"BulkMoveChannelsToGroupRequest": {
"required": [
"channelIds",
"group"
],
"type": "object",
"properties": {
"channelIds": {
"type": [
"null",
"array"
],
"items": {
"type": "integer",
"format": "int32"
}
},
"group": {
"type": [
"null",
"string"
]
}
}
},
"BulkRenumberChannelRequest": {
"required": [
"id",
"number"
],
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"number": {
"type": [
"null",
"string"
]
}
}
},
"BulkRenumberChannelsRequest": {
"required": [
"channels"
],
"type": "object",
"properties": {
"channels": {
"type": [
"null",
"array"
],
"items": {
"$ref": "#/components/schemas/BulkRenumberChannelRequest"
}
}
}
},
"ChannelIdleBehavior": {
"enum": [
"StopOnDisconnect",
@@ -3074,6 +3426,27 @@
],
"type": "string"
},
"ChannelNowPlayingResponseModel": {
"required": [
"title",
"startUtc",
"finishUtc"
],
"type": "object",
"properties": {
"title": {
"type": "string"
},
"startUtc": {
"type": "string",
"format": "date-time"
},
"finishUtc": {
"type": "string",
"format": "date-time"
}
}
},
"ChannelPlayoutMode": {
"enum": [
"Continuous",
@@ -3092,10 +3465,15 @@
"required": [
"id",
"number",
"sortNumber",
"name",
"group",
"categories",
"fFmpegProfile",
"language",
"streamingMode"
"streamingMode",
"isEnabled",
"showInEpg"
],
"type": "object",
"properties": {
@@ -3109,12 +3487,28 @@
"string"
]
},
"sortNumber": {
"type": "number",
"format": "double"
},
"name": {
"type": [
"null",
"string"
]
},
"group": {
"type": [
"null",
"string"
]
},
"categories": {
"type": [
"null",
"string"
]
},
"fFmpegProfile": {
"type": [
"null",
@@ -3132,6 +3526,12 @@
"null",
"string"
]
},
"isEnabled": {
"type": "boolean"
},
"showInEpg": {
"type": "boolean"
}
}
},
@@ -3142,6 +3542,37 @@
],
"type": "string"
},
"ChannelStateResponseModel": {
"required": [
"channelId",
"channelNumber",
"onAir",
"nowPlaying"
],
"type": "object",
"properties": {
"channelId": {
"type": "integer",
"format": "int32"
},
"channelNumber": {
"type": "string"
},
"onAir": {
"type": "boolean"
},
"nowPlaying": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/ChannelNowPlayingResponseModel"
}
]
}
}
},
"ChannelStreamSelectorMode": {
"enum": [
"Default",
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
</x-dc>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
---
name: chicorytv-design
description: Use this skill to generate well-branded interfaces and assets for ChicoryTV, either for production or throwaway prototypes/mocks/etc. Contains essential design guidelines, colors, type, fonts, assets, and UI kit components for prototyping.
user-invocable: true
---
Read the README.md file within this skill, and explore the other available files.
If creating visual artifacts (slides, mocks, throwaway prototypes, etc), copy assets out and create static HTML files for the user to view. If working on production code, you can copy assets and read the rules here to become an expert in designing with this brand.
If the user invokes this skill without any other guidance, ask them what they want to build or design, ask some questions, and act as an expert designer who outputs HTML artifacts _or_ production code, depending on the need.
Quick map:
- `readme.md` — brand context, content & visual foundations, iconography, file index.
- `styles.css` — the single entry point; link it and use the CSS custom properties.
- `tokens/` — color, type, spacing, radius/elevation/motion tokens + font loading.
- `components/` — React primitives (forms, data-display, feedback, navigation).
- `templates/chicorytv-admin/` — full admin shell to start from / copy screens out of (incl. the library-to-lineup Channel Builder).
- `assets/` — chicory-flower mark, wordmark, app icon.
+470
View File
@@ -0,0 +1,470 @@
{
"plugins": [
"react",
"import"
],
"rules": {
"react/forbid-elements": [
"warn",
{
"forbid": []
}
],
"no-restricted-imports": [
"warn",
{
"patterns": [
{
"group": [
"components/data-display/**",
"components/feedback/**",
"components/forms/**",
"components/navigation/**",
"design_handoff_channel_builder/**"
],
"message": "Import design-system components from 'index.js', not component internals."
}
]
}
],
"no-restricted-syntax": [
"warn",
{
"selector": "Literal[value=/#[0-9a-fA-F]{3,8}\\b/]",
"message": "Raw hex color — use a design-system color token via var()."
},
{
"selector": "Literal[value=/\\b\\d+px\\b/]",
"message": "Raw px value — use a design-system spacing token via var()."
},
{
"selector": "Literal[value=/font-family\\s*:\\s*(?!['\\\"]?(?:Geist|Geist Mono))/i]",
"message": "Font not provided by the design system. Available: Geist, Geist Mono."
},
{
"selector": "JSXOpeningElement[name.name='Badge'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|tone|solid|dot|style|key|ref|className|style|children)$/]",
"message": "<Badge> doesn't accept that prop. Declared props: children, tone, solid, dot, style."
},
{
"selector": "JSXOpeningElement[name.name='Badge'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:neutral|accent|ok|warn|error)$/]",
"message": "<Badge> tone must be one of 'neutral' | 'accent' | 'ok' | 'warn' | 'error'."
},
{
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|variant|size|startIcon|endIcon|disabled|loading|fullWidth|type|onClick|style|key|ref|className|style|children)$/]",
"message": "<Button> doesn't accept that prop. Declared props: children, variant, size, startIcon, endIcon, disabled, loading, fullWidth, type, onClick, style."
},
{
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:primary|secondary|ghost|danger)$/]",
"message": "<Button> variant must be one of 'primary' | 'secondary' | 'ghost' | 'danger'."
},
{
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md)$/]",
"message": "<Button> size must be one of 'sm' | 'md'."
},
{
"selector": "JSXOpeningElement[name.name='Button'] > JSXAttribute[name.name='type'] > Literal[value!=/^(?:button|submit|reset)$/]",
"message": "<Button> type must be one of 'button' | 'submit' | 'reset'."
},
{
"selector": "JSXOpeningElement[name.name='Card'] > JSXAttribute > JSXIdentifier[name!=/^(?:title|subtitle|actions|children|padded|inset|style|key|ref|className|style|children)$/]",
"message": "<Card> doesn't accept that prop. Declared props: title, subtitle, actions, children, padded, inset, style."
},
{
"selector": "JSXOpeningElement[name.name='ChannelLogo'] > JSXAttribute > JSXIdentifier[name!=/^(?:name|src|size|radius|style|key|ref|className|style|children)$/]",
"message": "<ChannelLogo> doesn't accept that prop. Declared props: name, src, size, radius, style."
},
{
"selector": "JSXOpeningElement[name.name='Checkbox'] > JSXAttribute > JSXIdentifier[name!=/^(?:checked|onChange|label|indeterminate|disabled|style|key|ref|className|style|children)$/]",
"message": "<Checkbox> doesn't accept that prop. Declared props: checked, onChange, label, indeterminate, disabled, style."
},
{
"selector": "JSXOpeningElement[name.name='IconButton'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|size|variant|active|disabled|title|onClick|style|key|ref|className|style|children)$/]",
"message": "<IconButton> doesn't accept that prop. Declared props: children, size, variant, active, disabled, title, onClick, style."
},
{
"selector": "JSXOpeningElement[name.name='IconButton'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md)$/]",
"message": "<IconButton> size must be one of 'sm' | 'md'."
},
{
"selector": "JSXOpeningElement[name.name='IconButton'] > JSXAttribute[name.name='variant'] > Literal[value!=/^(?:ghost|solid)$/]",
"message": "<IconButton> variant must be one of 'ghost' | 'solid'."
},
{
"selector": "JSXOpeningElement[name.name='Input'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|value|onChange|placeholder|leadingIcon|trailing|error|disabled|size|type|fullWidth|style|key|ref|className|style|children)$/]",
"message": "<Input> doesn't accept that prop. Declared props: label, value, onChange, placeholder, leadingIcon, trailing, error, disabled, size, type, fullWidth, style."
},
{
"selector": "JSXOpeningElement[name.name='Input'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md)$/]",
"message": "<Input> size must be one of 'sm' | 'md'."
},
{
"selector": "JSXOpeningElement[name.name='NavItem'] > JSXAttribute > JSXIdentifier[name!=/^(?:icon|label|active|badge|badgeTone|href|onClick|style|key|ref|className|style|children)$/]",
"message": "<NavItem> doesn't accept that prop. Declared props: icon, label, active, badge, badgeTone, href, onClick, style."
},
{
"selector": "JSXOpeningElement[name.name='NavItem'] > JSXAttribute[name.name='badgeTone'] > Literal[value!=/^(?:warn|error|accent)$/]",
"message": "<NavItem> badgeTone must be one of 'warn' | 'error' | 'accent'."
},
{
"selector": "JSXOpeningElement[name.name='ProgressBar'] > JSXAttribute > JSXIdentifier[name!=/^(?:value|tone|height|showLabel|style|key|ref|className|style|children)$/]",
"message": "<ProgressBar> doesn't accept that prop. Declared props: value, tone, height, showLabel, style."
},
{
"selector": "JSXOpeningElement[name.name='ProgressBar'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:accent|ok|warn|error)$/]",
"message": "<ProgressBar> tone must be one of 'accent' | 'ok' | 'warn' | 'error'."
},
{
"selector": "JSXOpeningElement[name.name='SelectOption'] > JSXAttribute > JSXIdentifier[name!=/^(?:value|label|key|ref|className|style|children)$/]",
"message": "<SelectOption> doesn't accept that prop. Declared props: value, label."
},
{
"selector": "JSXOpeningElement[name.name='Spinner'] > JSXAttribute > JSXIdentifier[name!=/^(?:size|tone|stroke|style|key|ref|className|style|children)$/]",
"message": "<Spinner> doesn't accept that prop. Declared props: size, tone, stroke, style."
},
{
"selector": "JSXOpeningElement[name.name='Spinner'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:accent|muted|ok)$/]",
"message": "<Spinner> tone must be one of 'accent' | 'muted' | 'ok'."
},
{
"selector": "JSXOpeningElement[name.name='Stat'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|value|unit|delta|deltaTone|icon|style|key|ref|className|style|children)$/]",
"message": "<Stat> doesn't accept that prop. Declared props: label, value, unit, delta, deltaTone, icon, style."
},
{
"selector": "JSXOpeningElement[name.name='Stat'] > JSXAttribute[name.name='deltaTone'] > Literal[value!=/^(?:up|down|neutral)$/]",
"message": "<Stat> deltaTone must be one of 'up' | 'down' | 'neutral'."
},
{
"selector": "JSXOpeningElement[name.name='StatusDot'] > JSXAttribute > JSXIdentifier[name!=/^(?:status|pulse|size|label|style|key|ref|className|style|children)$/]",
"message": "<StatusDot> doesn't accept that prop. Declared props: status, pulse, size, label, style."
},
{
"selector": "JSXOpeningElement[name.name='StatusDot'] > JSXAttribute[name.name='status'] > Literal[value!=/^(?:live|ok|warn|error|idle)$/]",
"message": "<StatusDot> status must be one of 'live' | 'ok' | 'warn' | 'error' | 'idle'."
},
{
"selector": "JSXOpeningElement[name.name='Switch'] > JSXAttribute > JSXIdentifier[name!=/^(?:checked|onChange|label|disabled|size|style|key|ref|className|style|children)$/]",
"message": "<Switch> doesn't accept that prop. Declared props: checked, onChange, label, disabled, size, style."
},
{
"selector": "JSXOpeningElement[name.name='Switch'] > JSXAttribute[name.name='size'] > Literal[value!=/^(?:sm|md)$/]",
"message": "<Switch> size must be one of 'sm' | 'md'."
},
{
"selector": "JSXOpeningElement[name.name='TabItem'] > JSXAttribute > JSXIdentifier[name!=/^(?:value|label|icon|count|key|ref|className|style|children)$/]",
"message": "<TabItem> doesn't accept that prop. Declared props: value, label, icon, count."
},
{
"selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute > JSXIdentifier[name!=/^(?:children|onRemove|icon|tone|style|key|ref|className|style|children)$/]",
"message": "<Tag> doesn't accept that prop. Declared props: children, onRemove, icon, tone, style."
},
{
"selector": "JSXOpeningElement[name.name='Tag'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:neutral|accent)$/]",
"message": "<Tag> tone must be one of 'neutral' | 'accent'."
},
{
"selector": "JSXOpeningElement[name.name='Toast'] > JSXAttribute > JSXIdentifier[name!=/^(?:tone|title|message|onClose|style|key|ref|className|style|children)$/]",
"message": "<Toast> doesn't accept that prop. Declared props: tone, title, message, onClose, style."
},
{
"selector": "JSXOpeningElement[name.name='Toast'] > JSXAttribute[name.name='tone'] > Literal[value!=/^(?:info|ok|warn|error)$/]",
"message": "<Toast> tone must be one of 'info' | 'ok' | 'warn' | 'error'."
},
{
"selector": "JSXOpeningElement[name.name='Tooltip'] > JSXAttribute > JSXIdentifier[name!=/^(?:label|placement|children|style|key|ref|className|style|children)$/]",
"message": "<Tooltip> doesn't accept that prop. Declared props: label, placement, children, style."
},
{
"selector": "JSXOpeningElement[name.name='Tooltip'] > JSXAttribute[name.name='placement'] > Literal[value!=/^(?:top|bottom|left|right)$/]",
"message": "<Tooltip> placement must be one of 'top' | 'bottom' | 'left' | 'right'."
}
]
},
"overrides": [
{
"files": [
"**/index.js"
],
"rules": {
"no-restricted-imports": "off"
}
}
],
"x-omelette": {
"components": {
"Badge": {
"replaces": []
},
"Button": {
"replaces": []
},
"Card": {
"replaces": []
},
"ChannelLogo": {
"replaces": []
},
"Checkbox": {
"replaces": []
},
"IconButton": {
"replaces": []
},
"Input": {
"replaces": []
},
"NavItem": {
"replaces": []
},
"ProgressBar": {
"replaces": []
},
"SelectOption": {
"replaces": []
},
"Spinner": {
"replaces": []
},
"Stat": {
"replaces": []
},
"StatusDot": {
"replaces": []
},
"Switch": {
"replaces": []
},
"TabItem": {
"replaces": []
},
"Tag": {
"replaces": []
},
"Toast": {
"replaces": []
},
"Tooltip": {
"replaces": []
}
},
"tokens": [
"--action-primary",
"--action-primary-hover",
"--action-primary-press",
"--border-control",
"--border-hairline",
"--control-h",
"--control-h-sm",
"--ctv-accent",
"--ctv-accent-hover",
"--ctv-accent-press",
"--ctv-accent-ring",
"--ctv-accent-soft",
"--ctv-bg",
"--ctv-bg-sunken",
"--ctv-border",
"--ctv-border-strong",
"--ctv-error",
"--ctv-error-soft",
"--ctv-live",
"--ctv-live-soft",
"--ctv-ok",
"--ctv-ok-soft",
"--ctv-secondary",
"--ctv-secondary-soft",
"--ctv-surface",
"--ctv-surface-2",
"--ctv-surface-3",
"--ctv-text",
"--ctv-text-faint",
"--ctv-text-muted",
"--ctv-warn",
"--ctv-warn-soft",
"--dur-base",
"--dur-fast",
"--dur-slow",
"--ease-out",
"--ease-standard",
"--focus-ring",
"--font-mono",
"--font-sans",
"--gap-inline",
"--leading-normal",
"--leading-snug",
"--leading-tight",
"--numeric-feature",
"--pad-card",
"--pad-cell-x",
"--pad-cell-y",
"--pad-control-x",
"--pad-control-y",
"--pad-page",
"--radius-lg",
"--radius-md",
"--radius-pill",
"--radius-sm",
"--radius-xs",
"--ring-focus",
"--row-h",
"--shadow-lg",
"--shadow-md",
"--shadow-none",
"--shadow-pop",
"--shadow-sm",
"--sidebar-w",
"--space-0",
"--space-1",
"--space-10",
"--space-11",
"--space-12",
"--space-2",
"--space-3",
"--space-4",
"--space-5",
"--space-6",
"--space-7",
"--space-8",
"--space-9",
"--status-error",
"--status-live",
"--status-ok",
"--status-warn",
"--surface-app",
"--surface-card",
"--surface-raised",
"--surface-selected",
"--text-2xl",
"--text-2xs",
"--text-3xl",
"--text-disabled",
"--text-lg",
"--text-md",
"--text-on-accent",
"--text-primary",
"--text-secondary",
"--text-sm",
"--text-xl",
"--text-xs",
"--topbar-h",
"--tracking-caps",
"--tracking-normal",
"--tracking-tight",
"--tracking-wide",
"--weight-bold",
"--weight-medium",
"--weight-normal",
"--weight-semibold"
],
"tokenKinds": {
"--ctv-bg": "color",
"--ctv-bg-sunken": "color",
"--ctv-surface": "color",
"--ctv-surface-2": "color",
"--ctv-surface-3": "color",
"--ctv-border": "color",
"--ctv-border-strong": "color",
"--ctv-text": "font",
"--ctv-text-muted": "font",
"--ctv-text-faint": "font",
"--ctv-accent": "color",
"--ctv-accent-hover": "color",
"--ctv-accent-press": "color",
"--ctv-accent-soft": "color",
"--ctv-accent-ring": "color",
"--ctv-secondary": "color",
"--ctv-secondary-soft": "color",
"--ctv-ok": "color",
"--ctv-ok-soft": "color",
"--ctv-warn": "color",
"--ctv-warn-soft": "color",
"--ctv-error": "color",
"--ctv-error-soft": "color",
"--ctv-live": "color",
"--ctv-live-soft": "color",
"--surface-app": "color",
"--surface-card": "color",
"--surface-raised": "color",
"--surface-selected": "color",
"--border-hairline": "color",
"--border-control": "color",
"--text-primary": "font",
"--text-secondary": "font",
"--text-disabled": "font",
"--text-on-accent": "font",
"--action-primary": "color",
"--action-primary-hover": "color",
"--action-primary-press": "color",
"--focus-ring": "color",
"--status-ok": "color",
"--status-warn": "color",
"--status-error": "color",
"--status-live": "color",
"--font-sans": "font",
"--font-mono": "font",
"--text-2xs": "font",
"--text-xs": "font",
"--text-sm": "font",
"--text-md": "font",
"--text-lg": "font",
"--text-xl": "font",
"--text-2xl": "font",
"--text-3xl": "font",
"--weight-normal": "font",
"--weight-medium": "font",
"--weight-semibold": "font",
"--weight-bold": "font",
"--leading-tight": "font",
"--leading-snug": "font",
"--leading-normal": "font",
"--tracking-tight": "font",
"--tracking-normal": "font",
"--tracking-wide": "font",
"--tracking-caps": "font",
"--numeric-feature": "other",
"--space-0": "spacing",
"--space-1": "spacing",
"--space-2": "spacing",
"--space-3": "spacing",
"--space-4": "spacing",
"--space-5": "spacing",
"--space-6": "spacing",
"--space-7": "spacing",
"--space-8": "spacing",
"--space-9": "spacing",
"--space-10": "spacing",
"--space-11": "spacing",
"--space-12": "spacing",
"--gap-inline": "spacing",
"--pad-control-x": "spacing",
"--pad-control-y": "spacing",
"--pad-card": "spacing",
"--pad-cell-x": "spacing",
"--pad-cell-y": "spacing",
"--pad-page": "spacing",
"--sidebar-w": "spacing",
"--topbar-h": "spacing",
"--row-h": "spacing",
"--control-h": "spacing",
"--control-h-sm": "spacing",
"--radius-xs": "radius",
"--radius-sm": "radius",
"--radius-md": "radius",
"--radius-lg": "radius",
"--radius-pill": "radius",
"--shadow-none": "shadow",
"--shadow-sm": "shadow",
"--shadow-md": "shadow",
"--shadow-lg": "shadow",
"--shadow-pop": "shadow",
"--ring-focus": "color",
"--ease-standard": "other",
"--ease-out": "other",
"--dur-fast": "other",
"--dur-base": "other",
"--dur-slow": "other"
},
"fontFamilies": [
"Geist",
"Geist Mono"
]
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
<g transform="translate(32 32)">
<rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(0)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(22.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(45)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(67.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(90)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(112.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(135)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(157.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(180)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(202.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(225)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(247.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(270)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(292.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(315)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(337.5)"></rect>
<circle r="8.5" fill="#0F1115"></circle>
<circle r="5" fill="#5B7CFA"></circle>
<circle r="2" fill="#0F1115"></circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
<rect width="64" height="64" rx="14" fill="#171A21"></rect>
<g transform="translate(32 32) scale(0.82)">
<rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(0)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(22.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(45)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(67.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(90)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(112.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(135)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(157.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(180)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(202.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(225)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(247.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(270)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(292.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(315)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(337.5)"></rect>
<circle r="8.5" fill="#171A21"></circle>
<circle r="5" fill="#5B7CFA"></circle>
<circle r="2" fill="#171A21"></circle>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 250 48" width="250" height="48" fill="none">
<g transform="translate(24 24) scale(0.62)">
<rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(0)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(22.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(45)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(67.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(90)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(112.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(135)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(157.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(180)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(202.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(225)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(247.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(270)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(292.5)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#5B7CFA" transform="rotate(315)"></rect><rect x="-3.1" y="-30" width="6.2" height="20" rx="3.1" fill="#8AA1FC" transform="rotate(337.5)"></rect>
<circle r="8.5" fill="#0F1115"></circle>
<circle r="5" fill="#5B7CFA"></circle>
<circle r="2" fill="#0F1115"></circle>
</g>
<text x="52" y="32" font-family="Geist, system-ui, sans-serif" font-size="24" font-weight="600" letter-spacing="-0.5" fill="#E6E9EF">Chicory<tspan fill="#5B7CFA">TV</tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
export interface BadgeProps {
children?: React.ReactNode;
/** Semantic color. `accent` doubles as the live/on-air tone. */
tone?: "neutral" | "accent" | "ok" | "warn" | "error";
/** Filled instead of tinted-soft. */
solid?: boolean;
/** Leading status dot. */
dot?: boolean;
style?: React.CSSProperties;
}
/**
* Compact status/label badge — streaming mode, HLS/MPEG-TS, health status.
*/
export function Badge(props: BadgeProps): JSX.Element;
@@ -0,0 +1,39 @@
import React from "react";
const tones = {
neutral: { fg: "var(--text-secondary)", bg: "var(--ctv-surface-3)", bd: "var(--border-hairline)" },
accent: { fg: "var(--ctv-accent)", bg: "var(--ctv-accent-soft)", bd: "rgba(91,124,250,0.35)" },
ok: { fg: "var(--status-ok)", bg: "var(--ctv-ok-soft)", bd: "rgba(63,185,132,0.35)" },
warn: { fg: "var(--status-warn)", bg: "var(--ctv-warn-soft)", bd: "rgba(224,168,61,0.35)" },
error: { fg: "var(--status-error)", bg: "var(--ctv-error-soft)", bd: "rgba(229,72,77,0.35)" },
};
/**
* Small status/label badge. Soft (tinted) or solid.
*/
export function Badge({ children, tone = "neutral", solid = false, dot = false, style = {} }) {
const t = tones[tone] || tones.neutral;
const solidBg = { neutral: "var(--ctv-surface-3)", accent: "var(--ctv-accent)", ok: "var(--status-ok)", warn: "var(--status-warn)", error: "var(--status-error)" };
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "5px",
height: "20px",
padding: dot ? "0 8px 0 6px" : "0 8px",
borderRadius: "var(--radius-xs)",
font: `var(--weight-medium) var(--text-2xs)/1 var(--font-sans)`,
letterSpacing: "0.02em",
color: solid ? (tone === "warn" ? "#1A1206" : "#fff") : t.fg,
background: solid ? solidBg[tone] : t.bg,
border: solid ? "1px solid transparent" : `1px solid ${t.bd}`,
whiteSpace: "nowrap",
...style,
}}
>
{dot && <span style={{ width: 6, height: 6, borderRadius: "50%", background: solid ? "rgba(255,255,255,0.9)" : t.fg }} />}
{children}
</span>
);
}
@@ -0,0 +1,11 @@
**Badge** — small status/label pill. Soft (tinted) by default; `solid` for stronger emphasis. Tones map to the status system; `accent` is also the live/on-air tone.
```jsx
<Badge tone="accent" dot>Live</Badge>
<Badge tone="ok">Healthy</Badge>
<Badge tone="warn">Not normalizing</Badge>
<Badge tone="neutral">HLS Segmenter</Badge>
<Badge tone="error" solid>Error</Badge>
```
Props: `tone` (neutral·accent·ok·warn·error), `solid`, `dot`.
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
/**
* @startingPoint section="Layout" subtitle="Card surface with header + actions" viewport="700x260"
*/
export interface CardProps {
title?: React.ReactNode;
subtitle?: React.ReactNode;
/** Right-aligned header actions (buttons, menu). */
actions?: React.ReactNode;
children?: React.ReactNode;
/** Body padding — turn off for flush tables/lists. */
padded?: boolean;
/** Sunken well background instead of card surface. */
inset?: boolean;
style?: React.CSSProperties;
}
/**
* Panel surface with optional header row; the base container for dashboard
* widgets, forms and detail panes.
*/
export function Card(props: CardProps): JSX.Element;
@@ -0,0 +1,38 @@
import React from "react";
/**
* Surface container. Optional header (title + actions) and body padding.
*/
export function Card({ title, subtitle, actions = null, children, padded = true, inset = false, style = {} }) {
return (
<div
style={{
background: inset ? "var(--ctv-bg-sunken)" : "var(--surface-card)",
border: "1px solid var(--border-hairline)",
borderRadius: "var(--radius-md)",
overflow: "hidden",
...style,
}}
>
{(title || actions) && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
padding: "13px 16px",
borderBottom: "1px solid var(--border-hairline)",
}}
>
<div style={{ minWidth: 0 }}>
{title && <div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{title}</div>}
{subtitle && <div style={{ marginTop: 3, font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{subtitle}</div>}
</div>
{actions && <div style={{ display: "flex", alignItems: "center", gap: "8px", flex: "0 0 auto" }}>{actions}</div>}
</div>
)}
<div style={{ padding: padded ? "16px" : 0 }}>{children}</div>
</div>
);
}
@@ -0,0 +1,10 @@
**Card** — the panel surface behind dashboard widgets, forms and detail panes. Header row is optional; set `padded={false}` when the body is a flush table or list.
```jsx
<Card title="Recent activity" actions={<Button size="sm" variant="ghost">View all</Button>}>
</Card>
<Card padded={false}><ChannelTable/></Card>
```
Props: `title`, `subtitle`, `actions`, `padded`, `inset`.
+17
View File
@@ -0,0 +1,17 @@
import * as React from "react";
export interface ChannelLogoProps {
/** Channel name — seeds the generated initials + color when no image. */
name?: string;
/** Logo image URL; falls back to a generated chip when absent. */
src?: string | null;
size?: number;
radius?: string;
style?: React.CSSProperties;
}
/**
* Square channel logo; shows the uploaded image or a deterministic
* initials chip keyed to the channel name (like ErsatzTV's generated logos).
*/
export function ChannelLogo(props: ChannelLogoProps): JSX.Element;
@@ -0,0 +1,51 @@
import React from "react";
const palette = [
["#5B7CFA", "#2E3A66"], ["#3FB984", "#1E4536"], ["#E0A83D", "#4A3818"],
["#E5484D", "#4A1F21"], ["#B06CF0", "#38235A"], ["#48B0C8", "#193E47"],
];
function hashIndex(str) {
let h = 0;
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
return h % palette.length;
}
/**
* Channel logo tile. Renders an image when given, else a generated
* initials chip keyed to the channel name — mirrors ErsatzTV's logo/gen.
*/
export function ChannelLogo({ name = "", src = null, size = 40, radius = "var(--radius-sm)", style = {} }) {
const [fg, bg] = palette[hashIndex(name)];
const initials = name
.split(/[\s\-|:]+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase() || "?";
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: size,
height: size,
flex: "0 0 auto",
overflow: "hidden",
borderRadius: radius,
background: src ? "var(--ctv-bg-sunken)" : bg,
border: "1px solid var(--border-hairline)",
...style,
}}
>
{src ? (
<img src={src} alt={name} style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
) : (
<span style={{ font: `var(--weight-semibold) ${Math.round(size * 0.34)}px/1 var(--font-mono)`, color: fg, letterSpacing: "0.02em" }}>{initials}</span>
)}
</span>
);
}
@@ -0,0 +1,8 @@
**ChannelLogo** — square channel avatar. Given `src`, shows the logo; otherwise generates a stable initials chip + color from `name` (mirrors ErsatzTV's `iptv/logos/gen`).
```jsx
<ChannelLogo name="Retro Cartoons" size={40} />
<ChannelLogo name="News 24" src="/logos/news24.png" />
```
Props: `name`, `src`, `size`, `radius`.
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
export interface ProgressBarProps {
/** 0100. Pass null/undefined for an indeterminate sweep. */
value?: number | null;
tone?: "accent" | "ok" | "warn" | "error";
height?: number;
/** Append a mono percentage on the right. */
showLabel?: boolean;
style?: React.CSSProperties;
}
/**
* Slim progress bar for library scans, playout builds and transcodes.
*/
export function ProgressBar(props: ProgressBarProps): JSX.Element;
@@ -0,0 +1,32 @@
import React from "react";
const toneColor = {
accent: "var(--action-primary)",
ok: "var(--status-ok)",
warn: "var(--status-warn)",
error: "var(--status-error)",
};
/**
* Thin determinate/indeterminate progress bar — library scans, transcodes.
*/
export function ProgressBar({ value = null, tone = "accent", height = 6, showLabel = false, style = {} }) {
const c = toneColor[tone] || toneColor.accent;
const indeterminate = value == null;
const pct = Math.max(0, Math.min(100, value ?? 0));
return (
<div style={{ display: "flex", alignItems: "center", gap: "10px", ...style }}>
<div style={{ position: "relative", flex: 1, height, borderRadius: "var(--radius-pill)", background: "var(--ctv-surface-3)", overflow: "hidden" }}>
<style>{`@keyframes ctv-indet{0%{left:-40%}100%{left:100%}}`}</style>
{indeterminate ? (
<span style={{ position: "absolute", top: 0, bottom: 0, width: "40%", borderRadius: "inherit", background: c, animation: "ctv-indet 1.1s var(--ease-standard) infinite" }} />
) : (
<span style={{ position: "absolute", top: 0, left: 0, bottom: 0, width: `${pct}%`, borderRadius: "inherit", background: c, transition: "width var(--dur-slow) var(--ease-out)" }} />
)}
</div>
{showLabel && !indeterminate && (
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)", color: "var(--text-secondary)", minWidth: 34, textAlign: "right", fontVariantNumeric: "tabular-nums" }}>{Math.round(pct)}%</span>
)}
</div>
);
}
@@ -0,0 +1,8 @@
**ProgressBar** — slim bar for library scans, playout builds and transcodes. Omit `value` for an indeterminate sweep (queued/starting work).
```jsx
<ProgressBar value={62} showLabel />
<ProgressBar value={null} tone="accent" /> {/* scanning… */}
```
Props: `value` (0100 or null), `tone` (accent·ok·warn·error), `height`, `showLabel`.
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react";
export interface StatProps {
label: string;
/** The metric value — rendered in tabular mono. */
value: React.ReactNode;
unit?: string;
/** Small change indicator, e.g. "+3" or "2 warnings". */
delta?: React.ReactNode;
deltaTone?: "up" | "down" | "neutral";
icon?: React.ReactNode;
style?: React.CSSProperties;
}
/**
* Dashboard KPI — mono tabular value with label, optional delta and icon.
*/
export function Stat(props: StatProps): JSX.Element;
@@ -0,0 +1,23 @@
import React from "react";
/**
* Dashboard metric. Big mono value, label, optional delta + icon.
*/
export function Stat({ label, value, unit, delta = null, deltaTone = "neutral", icon = null, style = {} }) {
const dc = { up: "var(--status-ok)", down: "var(--status-error)", neutral: "var(--text-secondary)" };
return (
<div style={{ display: "flex", flexDirection: "column", gap: "8px", ...style }}>
<div style={{ display: "flex", alignItems: "center", gap: "7px" }}>
{icon && <span style={{ display: "inline-flex", color: "var(--text-disabled)" }}>{icon}</span>}
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", letterSpacing: "0.03em", color: "var(--text-secondary)" }}>{label}</span>
</div>
<div style={{ display: "flex", alignItems: "baseline", gap: "6px" }}>
<span style={{ font: "var(--weight-semibold) var(--text-2xl)/1 var(--font-mono)", color: "var(--text-primary)", letterSpacing: "-0.01em", fontVariantNumeric: "tabular-nums" }}>{value}</span>
{unit && <span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{unit}</span>}
{delta != null && (
<span style={{ marginLeft: 2, font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)", color: dc[deltaTone] }}>{delta}</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,9 @@
**Stat** — a dashboard KPI. Value is set in tabular mono so columns of numbers align. Use for channel counts, active streams, uptime.
```jsx
<Stat label="Active streams" value="7" icon={<Ico n="Radio"/>} delta="+2" deltaTone="up" />
<Stat label="Channels" value="128" />
<Stat label="Uptime" value="14d 06h" />
```
Props: `label`, `value`, `unit`, `delta`, `deltaTone` (up·down·neutral), `icon`.
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
export interface StatusDotProps {
/** `live` auto-pulses in accent; others are static. */
status?: "live" | "ok" | "warn" | "error" | "idle";
/** Force the pulsing ring on any status. */
pulse?: boolean;
size?: number;
label?: string;
style?: React.CSSProperties;
}
/**
* Small colored status dot; pulses for live/on-air channels.
*/
export function StatusDot(props: StatusDotProps): JSX.Element;
@@ -0,0 +1,37 @@
import React from "react";
const colors = {
live: "var(--status-live)",
ok: "var(--status-ok)",
warn: "var(--status-warn)",
error: "var(--status-error)",
idle: "var(--text-disabled)",
};
/**
* Status dot with optional pulsing ring — on-air / live indicators.
*/
export function StatusDot({ status = "idle", pulse = false, size = 8, label, style = {} }) {
const c = colors[status] || colors.idle;
const doPulse = pulse || status === "live";
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: "7px", ...style }}>
<span style={{ position: "relative", width: size, height: size, flex: "0 0 auto" }}>
{doPulse && (
<span
style={{
position: "absolute",
inset: 0,
borderRadius: "50%",
background: c,
animation: "ctv-ping 1.6s cubic-bezier(0,0,0.2,1) infinite",
}}
/>
)}
<span style={{ position: "absolute", inset: 0, borderRadius: "50%", background: c, boxShadow: doPulse ? `0 0 6px ${c}` : "none" }} />
<style>{`@keyframes ctv-ping{0%{transform:scale(1);opacity:.55}70%,100%{transform:scale(2.4);opacity:0}}`}</style>
</span>
{label && <span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>}
</span>
);
}
@@ -0,0 +1,9 @@
**StatusDot** — tiny colored dot for channel/transcode state. `live` pulses in accent (on-air), the rest are static status colors.
```jsx
<StatusDot status="live" label="On air" />
<StatusDot status="ok" label="Idle" />
<StatusDot status="error" label="Failed" />
```
Props: `status` (live·ok·warn·error·idle), `pulse`, `size`, `label`.
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
export interface TagProps {
children?: React.ReactNode;
/** Show a remove (×) button and handle its click. */
onRemove?: () => void;
/** Optional leading icon. */
icon?: React.ReactNode;
tone?: "neutral" | "accent";
style?: React.CSSProperties;
}
/**
* Chip for filters, smart-collection criteria and media tags; optionally removable.
*/
export function Tag(props: TagProps): JSX.Element;
@@ -0,0 +1,54 @@
import React from "react";
/**
* Removable chip/tag — smart-collection filters, tags on media.
*/
export function Tag({ children, onRemove, icon = null, tone = "neutral", style = {} }) {
const [hover, setHover] = React.useState(false);
const tint = tone === "accent";
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
height: "22px",
padding: onRemove ? "0 5px 0 8px" : "0 9px",
borderRadius: "var(--radius-xs)",
font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)",
color: tint ? "var(--ctv-accent)" : "var(--text-primary)",
background: tint ? "var(--ctv-accent-soft)" : "var(--ctv-surface-3)",
border: `1px solid ${tint ? "rgba(91,124,250,0.35)" : "var(--border-hairline)"}`,
whiteSpace: "nowrap",
...style,
}}
>
{icon && <span style={{ display: "inline-flex", color: "var(--text-disabled)" }}>{icon}</span>}
{children}
{onRemove && (
<button
type="button"
onClick={onRemove}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
aria-label="Remove"
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 15,
height: 15,
padding: 0,
border: "none",
borderRadius: "3px",
cursor: "pointer",
color: hover ? "var(--text-primary)" : "var(--text-disabled)",
background: hover ? "rgba(255,255,255,0.08)" : "transparent",
}}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" /></svg>
</button>
)}
</span>
);
}
@@ -0,0 +1,8 @@
**Tag** — chip for filters, smart-collection criteria, and media tags. Pass `onRemove` to show a × button.
```jsx
<Tag>genre: comedy</Tag>
<Tag tone="accent" onRemove={() => drop(i)}>released 1990</Tag>
```
Props: `onRemove`, `icon`, `tone` (neutral·accent).
@@ -0,0 +1,71 @@
<!-- @dsCard group="Components" viewport="700x330" name="Data display" subtitle="Badge, StatusDot, Tag, ChannelLogo, Stat, ProgressBar, Card" -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../../styles.css">
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="https://unpkg.com/lucide@0.544.0/dist/umd/lucide.min.js"></script>
<script src="../../_ds_bundle.js"></script>
<style>
body { margin: 0; background: var(--surface-app); color: var(--text-primary); font-family: var(--font-sans); }
#root { padding: 22px 24px; }
.row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Badge, StatusDot, Tag, ChannelLogo, Stat, ProgressBar, Card } = NS;
const Ico = ({ n, s = 15 }) => { const r = React.useRef(null); React.useEffect(() => { if (r.current) { r.current.innerHTML=""; const el = window.lucide.createElement(window.lucide.icons[n]); el.setAttribute("width", s); el.setAttribute("height", s); r.current.appendChild(el);} }); return <span ref={r} style={{ display: "inline-flex" }} />; };
function Demo() {
return (
<div>
<div className="row">
<Badge tone="accent" dot>Live</Badge>
<Badge tone="ok">Healthy</Badge>
<Badge tone="warn">Not normalizing</Badge>
<Badge tone="error" solid>Error</Badge>
<Badge tone="neutral">HLS Segmenter</Badge>
<span style={{ width: 8 }} />
<StatusDot status="live" label="On air" />
<StatusDot status="ok" label="Idle" />
<StatusDot status="error" label="Failed" />
</div>
<div className="row">
<Tag icon={<Ico n="Tag" s={12} />}>genre: comedy</Tag>
<Tag tone="accent" onRemove={() => {}}>released 1990</Tag>
<Tag onRemove={() => {}}>rating: PG</Tag>
<span style={{ width: 8 }} />
<ChannelLogo name="Retro Cartoons" size={38} />
<ChannelLogo name="News 24" size={38} />
<ChannelLogo name="Late Night" size={38} />
</div>
<div style={{ display: "grid", gridTemplateColumns: "1.1fr 1fr", gap: 16, alignItems: "start" }}>
<Card padded={true} style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 20 }}>
<Stat label="Active streams" value="7" icon={<Ico n="Radio" />} delta="+2" deltaTone="up" />
<Stat label="Channels" value="128" icon={<Ico n="Tv" />} />
<Stat label="Scheduled" value="1,204" icon={<Ico n="CalendarClock" />} />
<Stat label="Uptime" value="14d 06h" icon={<Ico n="Activity" />} />
</Card>
<Card title="Library scan" subtitle="Movies · local">
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<ProgressBar value={62} showLabel />
<ProgressBar value={null} tone="accent" />
<ProgressBar value={100} tone="ok" showLabel />
</div>
</Card>
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<Demo />);
</script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
import * as React from "react";
export interface SpinnerProps {
size?: number;
tone?: "accent" | "muted" | "ok";
stroke?: number;
style?: React.CSSProperties;
}
/**
* Indeterminate ring spinner — inline busy state for locked playouts/rows.
*/
export function Spinner(props: SpinnerProps): JSX.Element;
@@ -0,0 +1,17 @@
import React from "react";
/**
* Indeterminate ring spinner.
*/
export function Spinner({ size = 18, tone = "accent", stroke = 2.4, style = {} }) {
const c = { accent: "var(--action-primary)", muted: "var(--text-disabled)", ok: "var(--status-ok)" }[tone] || "var(--action-primary)";
return (
<span style={{ display: "inline-flex", ...style }}>
<style>{`@keyframes ctv-spin{to{transform:rotate(360deg)}}`}</style>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ animation: "ctv-spin 0.7s linear infinite" }}>
<circle cx="12" cy="12" r="9" stroke={c} strokeOpacity="0.2" strokeWidth={stroke} />
<path d="M21 12a9 9 0 0 0-9-9" stroke={c} strokeWidth={stroke} strokeLinecap="round" />
</svg>
</span>
);
}
@@ -0,0 +1,7 @@
**Spinner** — inline busy indicator for locked playouts, in-flight row actions, and loading panes.
```jsx
<Spinner size={16} tone="accent" />
```
Props: `size`, `tone` (accent·muted·ok), `stroke`.
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
export interface ToastProps {
tone?: "info" | "ok" | "warn" | "error";
title?: React.ReactNode;
message?: React.ReactNode;
/** Show a dismiss button. */
onClose?: () => void;
style?: React.CSSProperties;
}
/**
* Toast / inline alert with a tone-colored accent edge — save confirmations,
* scan results, playout-build failures.
*/
export function Toast(props: ToastProps): JSX.Element;
@@ -0,0 +1,46 @@
import React from "react";
const map = {
ok: { c: "var(--status-ok)", bg: "var(--ctv-ok-soft)", icon: "M5 12.5l4.5 4.5L19 7" },
warn: { c: "var(--status-warn)", bg: "var(--ctv-warn-soft)", icon: "M12 8v5M12 17h.01" },
error: { c: "var(--status-error)", bg: "var(--ctv-error-soft)", icon: "M12 8v5M12 17h.01" },
info: { c: "var(--ctv-accent)", bg: "var(--ctv-accent-soft)", icon: "M12 11v5M12 8h.01" },
};
/**
* Toast / inline alert. Icon, message, optional close.
*/
export function Toast({ tone = "info", title, message, onClose, style = {} }) {
const t = map[tone] || map.info;
return (
<div
role="status"
style={{
display: "flex",
alignItems: "flex-start",
gap: "11px",
width: "min(380px, 100%)",
padding: "12px 13px",
background: "var(--surface-raised)",
border: "1px solid var(--border-hairline)",
borderLeft: `2px solid ${t.c}`,
borderRadius: "var(--radius-md)",
boxShadow: "var(--shadow-md)",
...style,
}}
>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 22, height: 22, borderRadius: "var(--radius-xs)", background: t.bg, flex: "0 0 auto", marginTop: 1 }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke={t.c} strokeWidth="0" fill="none" /><path d={t.icon} stroke={t.c} strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" /></svg>
</span>
<div style={{ flex: 1, minWidth: 0 }}>
{title && <div style={{ font: "var(--weight-semibold) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{title}</div>}
{message && <div style={{ marginTop: title ? 2 : 0, font: "var(--text-xs)/1.45 var(--font-sans)", color: "var(--text-secondary)" }}>{message}</div>}
</div>
{onClose && (
<button type="button" onClick={onClose} aria-label="Dismiss" style={{ display: "inline-flex", padding: 3, border: "none", background: "transparent", color: "var(--text-disabled)", cursor: "pointer", flex: "0 0 auto" }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /></svg>
</button>
)}
</div>
);
}
@@ -0,0 +1,8 @@
**Toast** — transient notification or inline alert with a tone-colored left edge. Use for save confirmations, scan results, and playout-build failures.
```jsx
<Toast tone="ok" title="Channel saved" message="Retro Cartoons is now on air." onClose={dismiss} />
<Toast tone="error" title="Playout build failed" message="No items match the schedule." />
```
Props: `tone` (info·ok·warn·error), `title`, `message`, `onClose`.
+13
View File
@@ -0,0 +1,13 @@
import * as React from "react";
export interface TooltipProps {
label: React.ReactNode;
placement?: "top" | "bottom" | "left" | "right";
children: React.ReactNode;
style?: React.CSSProperties;
}
/**
* Hover tooltip wrapping any trigger — icon-button labels, truncated text.
*/
export function Tooltip(props: TooltipProps): JSX.Element;
@@ -0,0 +1,45 @@
import React from "react";
/**
* Hover tooltip. Wraps a trigger; shows a small dark label.
*/
export function Tooltip({ label, placement = "top", children, style = {} }) {
const [show, setShow] = React.useState(false);
const pos = {
top: { bottom: "calc(100% + 7px)", left: "50%", transform: "translateX(-50%)" },
bottom: { top: "calc(100% + 7px)", left: "50%", transform: "translateX(-50%)" },
left: { right: "calc(100% + 7px)", top: "50%", transform: "translateY(-50%)" },
right: { left: "calc(100% + 7px)", top: "50%", transform: "translateY(-50%)" },
}[placement];
return (
<span
onMouseEnter={() => setShow(true)}
onMouseLeave={() => setShow(false)}
style={{ position: "relative", display: "inline-flex", ...style }}
>
{children}
{show && label && (
<span
role="tooltip"
style={{
position: "absolute",
zIndex: 50,
...pos,
padding: "5px 9px",
borderRadius: "var(--radius-sm)",
background: "var(--ctv-surface-3)",
border: "1px solid var(--border-control)",
boxShadow: "var(--shadow-pop)",
color: "var(--text-primary)",
font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)",
whiteSpace: "nowrap",
pointerEvents: "none",
}}
>
{label}
</span>
)}
</span>
);
}
@@ -0,0 +1,7 @@
**Tooltip** — hover label for icon buttons and truncated text. Wrap the trigger element.
```jsx
<Tooltip label="Reset playout"><IconButton title="Reset"><Ico n="RefreshCw"/></IconButton></Tooltip>
```
Props: `label`, `placement` (top·bottom·left·right).
@@ -0,0 +1,50 @@
<!-- @dsCard group="Components" viewport="700x260" name="Feedback" subtitle="Toast, Tooltip, Spinner" -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../../styles.css">
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="https://unpkg.com/lucide@0.544.0/dist/umd/lucide.min.js"></script>
<script src="../../_ds_bundle.js"></script>
<style>
body { margin: 0; background: var(--surface-app); color: var(--text-primary); font-family: var(--font-sans); }
#root { padding: 22px 24px; display: flex; gap: 22px; align-items: flex-start; flex-wrap: wrap; }
.lbl { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-disabled); margin-bottom: 12px; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Toast, Tooltip, Spinner, IconButton } = NS;
const Ico = ({ n, s = 16 }) => { const r = React.useRef(null); React.useEffect(() => { if (r.current){ r.current.innerHTML=""; const el = window.lucide.createElement(window.lucide.icons[n]); el.setAttribute("width", s); el.setAttribute("height", s); r.current.appendChild(el);} }); return <span ref={r} style={{ display: "inline-flex" }} />; };
function Demo() {
return (
<React.Fragment>
<div style={{ display: "flex", flexDirection: "column", gap: 12, width: 380 }}>
<Toast tone="ok" title="Channel saved" message="Retro Cartoons is now on air." onClose={() => {}} />
<Toast tone="warn" title="FFmpeg profile is not normalizing" message="Playback may stutter in some browsers." onClose={() => {}} />
<Toast tone="error" title="Playout build failed" message="No items match the schedule window." onClose={() => {}} />
</div>
<div>
<div className="lbl">Tooltip · Spinner</div>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<Tooltip label="Reset playout"><IconButton title="Reset"><Ico n="RefreshCw" /></IconButton></Tooltip>
<Tooltip label="Troubleshoot" placement="bottom"><IconButton title="Info"><Ico n="Info" /></IconButton></Tooltip>
<Spinner size={18} tone="accent" />
<Spinner size={18} tone="muted" />
</div>
</div>
</React.Fragment>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<Demo />);
</script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react";
/**
* @startingPoint section="Forms" subtitle="Buttons in every variant & size" viewport="700x330"
*/
export interface ButtonProps {
children?: React.ReactNode;
/** Visual role. `primary` is the single accent action; use one per view. */
variant?: "primary" | "secondary" | "ghost" | "danger";
size?: "sm" | "md";
/** Icon element rendered before the label (16px stroke icon). */
startIcon?: React.ReactNode;
endIcon?: React.ReactNode;
disabled?: boolean;
/** Shows an inline spinner and disables the button. */
loading?: boolean;
fullWidth?: boolean;
type?: "button" | "submit" | "reset";
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
style?: React.CSSProperties;
}
/**
* Primary action button for ChicoryTV. One `primary` per view; everything else
* is `secondary`/`ghost`. `danger` for destructive confirmations.
*/
export function Button(props: ButtonProps): JSX.Element;
+107
View File
@@ -0,0 +1,107 @@
import React from "react";
const sizeMap = {
sm: { height: "var(--control-h-sm)", padding: "0 12px", font: "var(--text-xs)" },
md: { height: "var(--control-h)", padding: "0 14px", font: "var(--text-sm)" },
};
const variantStyle = {
primary: {
background: "var(--action-primary)",
color: "var(--text-on-accent)",
border: "1px solid transparent",
},
secondary: {
background: "var(--ctv-surface-2)",
color: "var(--text-primary)",
border: "1px solid var(--border-control)",
},
ghost: {
background: "transparent",
color: "var(--text-secondary)",
border: "1px solid transparent",
},
danger: {
background: "var(--ctv-error-soft)",
color: "var(--status-error)",
border: "1px solid rgba(229,72,77,0.35)",
},
};
const hoverBg = {
primary: "var(--action-primary-hover)",
secondary: "var(--ctv-surface-3)",
ghost: "var(--ctv-surface-2)",
danger: "rgba(229,72,77,0.22)",
};
/**
* ChicoryTV primary action button.
*/
export function Button({
children,
variant = "primary",
size = "md",
startIcon = null,
endIcon = null,
disabled = false,
loading = false,
fullWidth = false,
onClick,
type = "button",
style = {},
...rest
}) {
const [hover, setHover] = React.useState(false);
const [active, setActive] = React.useState(false);
const s = sizeMap[size] || sizeMap.md;
const v = variantStyle[variant] || variantStyle.primary;
const isDisabled = disabled || loading;
return (
<button
type={type}
disabled={isDisabled}
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => { setHover(false); setActive(false); }}
onMouseDown={() => setActive(true)}
onMouseUp={() => setActive(false)}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: "7px",
height: s.height,
padding: s.padding,
width: fullWidth ? "100%" : "auto",
font: `var(--weight-medium) ${s.font}/1 var(--font-sans)`,
letterSpacing: "0.01em",
borderRadius: "var(--radius-sm)",
cursor: isDisabled ? "not-allowed" : "pointer",
opacity: isDisabled ? 0.45 : 1,
whiteSpace: "nowrap",
transition: "background var(--dur-fast) var(--ease-standard), transform var(--dur-fast) var(--ease-standard)",
transform: active && !isDisabled ? "translateY(0.5px)" : "none",
...v,
background: !isDisabled && hover ? hoverBg[variant] : v.background,
...style,
}}
{...rest}
>
{loading ? <Spinner16 /> : startIcon}
{children}
{!loading && endIcon}
</button>
);
}
function Spinner16() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{ animation: "ctv-spin 0.7s linear infinite" }}>
<style>{`@keyframes ctv-spin{to{transform:rotate(360deg)}}`}</style>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="3" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
</svg>
);
}
@@ -0,0 +1,11 @@
**Button** — the primary action control. Use exactly one `primary` button per view (add channel, save); everything else is `secondary` or `ghost`. `danger` is reserved for destructive confirmations.
```jsx
<Button variant="primary" startIcon={<PlusIcon/>}>Add Channel</Button>
<Button variant="secondary">Edit Numbers</Button>
<Button variant="ghost" size="sm">Cancel</Button>
<Button variant="danger">Delete</Button>
<Button variant="primary" loading>Saving</Button>
```
Props: `variant` (primary·secondary·ghost·danger), `size` (sm·md), `startIcon`/`endIcon`, `loading`, `disabled`, `fullWidth`.
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
export interface CheckboxProps {
checked?: boolean;
onChange?: (next: boolean) => void;
label?: string;
/** Mixed state for "select all" headers. */
indeterminate?: boolean;
disabled?: boolean;
style?: React.CSSProperties;
}
/**
* Checkbox for multi-select rows and option lists; supports indeterminate.
*/
export function Checkbox(props: CheckboxProps): JSX.Element;
@@ -0,0 +1,37 @@
import React from "react";
/**
* Checkbox with label. Controlled via checked/onChange.
*/
export function Checkbox({ checked = false, onChange, disabled = false, label, indeterminate = false, style = {} }) {
const toggle = () => { if (!disabled && onChange) onChange(!checked); };
const on = checked || indeterminate;
return (
<label style={{ display: "inline-flex", alignItems: "center", gap: "8px", cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1, ...style }}>
<span
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
onClick={toggle}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 17,
height: 17,
flex: "0 0 auto",
borderRadius: "var(--radius-xs)",
background: on ? "var(--action-primary)" : "var(--ctv-bg-sunken)",
border: `1px solid ${on ? "transparent" : "var(--border-control)"}`,
transition: "background var(--dur-fast), border-color var(--dur-fast)",
}}
>
{indeterminate ? (
<svg width="11" height="11" viewBox="0 0 24 24"><path d="M6 12h12" stroke="#fff" strokeWidth="3" strokeLinecap="round" /></svg>
) : checked ? (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"><path d="M5 12.5l4.5 4.5L19 7" stroke="#fff" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" /></svg>
) : null}
</span>
{label && <span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{label}</span>}
</label>
);
}
@@ -0,0 +1,8 @@
**Checkbox** — multi-select in tables (collection items, bulk actions) and option lists. Use `indeterminate` on a header "select all".
```jsx
<Checkbox checked={sel} onChange={setSel} label="Include in EPG" />
<Checkbox indeterminate onChange={selectAll} />
```
Props: `checked`, `onChange(next)`, `label`, `indeterminate`, `disabled`.
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
export interface IconButtonProps {
/** A 1618px stroke icon element. */
children?: React.ReactNode;
size?: "sm" | "md";
variant?: "ghost" | "solid";
/** Persistent pressed/selected state (e.g. active view toggle). */
active?: boolean;
disabled?: boolean;
/** Tooltip + aria-label — always provide one. */
title?: string;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
style?: React.CSSProperties;
}
/**
* Square icon-only button for dense row actions and toolbars.
*/
export function IconButton(props: IconButtonProps): JSX.Element;
@@ -0,0 +1,59 @@
import React from "react";
/**
* Compact square icon button — table row actions, toolbars.
*/
export function IconButton({
children,
size = "md",
variant = "ghost",
disabled = false,
active = false,
title,
onClick,
style = {},
...rest
}) {
const [hover, setHover] = React.useState(false);
const dim = size === "sm" ? 28 : 32;
const base =
variant === "solid"
? { background: "var(--action-primary)", color: "var(--text-on-accent)" }
: { background: active ? "var(--ctv-surface-3)" : "transparent", color: active ? "var(--text-primary)" : "var(--text-secondary)" };
const hoverBg =
variant === "solid" ? "var(--action-primary-hover)" : "var(--ctv-surface-2)";
return (
<button
type="button"
title={title}
aria-label={title}
disabled={disabled}
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: dim,
height: dim,
flex: "0 0 auto",
borderRadius: "var(--radius-sm)",
border: "1px solid transparent",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.4 : 1,
color: base.color,
background: !disabled && hover ? hoverBg : base.background,
transition: "background var(--dur-fast) var(--ease-standard), color var(--dur-fast) var(--ease-standard)",
...(!disabled && hover && variant !== "solid" ? { color: "var(--text-primary)" } : {}),
...style,
}}
{...rest}
>
{children}
</button>
);
}
@@ -0,0 +1,9 @@
**IconButton** — icon-only square button for table row actions (edit, preview, delete) and toolbars. Always pass `title` for the tooltip/aria-label.
```jsx
<IconButton title="Edit channel"><EditIcon/></IconButton>
<IconButton title="Preview" variant="solid"><PlayIcon/></IconButton>
<IconButton title="Delete" disabled><TrashIcon/></IconButton>
```
Props: `size` (sm·md), `variant` (ghost·solid), `active`, `disabled`, `title`.
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react";
export interface InputProps {
label?: string;
value?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
/** 16px stroke icon shown inside, left of the field (e.g. search). */
leadingIcon?: React.ReactNode;
/** Element pinned to the right inside the field (clear button, unit). */
trailing?: React.ReactNode;
/** Error message — turns the border red and shows text below. */
error?: string | null;
disabled?: boolean;
size?: "sm" | "md";
type?: string;
fullWidth?: boolean;
style?: React.CSSProperties;
}
/**
* Text field on a sunken well with focus ring and optional icon/error.
*/
export function Input(props: InputProps): JSX.Element;
+90
View File
@@ -0,0 +1,90 @@
import React from "react";
/**
* Text input with optional label, leading icon and error state.
*/
export function Input({
label,
value,
onChange,
placeholder,
leadingIcon = null,
trailing = null,
error = null,
disabled = false,
size = "md",
type = "text",
fullWidth = true,
style = {},
...rest
}) {
const [focus, setFocus] = React.useState(false);
const height = size === "sm" ? "var(--control-h-sm)" : "var(--control-h)";
const borderColor = error
? "var(--status-error)"
: focus
? "var(--action-primary)"
: "var(--border-control)";
return (
<label style={{ display: "block", width: fullWidth ? "100%" : "auto" }}>
{label && (
<span
style={{
display: "block",
marginBottom: "6px",
font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)",
color: "var(--text-secondary)",
}}
>
{label}
</span>
)}
<span
style={{
display: "flex",
alignItems: "center",
gap: "8px",
height,
padding: "0 10px",
background: "var(--ctv-bg-sunken)",
border: `1px solid ${borderColor}`,
borderRadius: "var(--radius-sm)",
boxShadow: focus && !error ? "0 0 0 3px var(--focus-ring)" : "none",
transition: "border-color var(--dur-fast) var(--ease-standard), box-shadow var(--dur-fast) var(--ease-standard)",
opacity: disabled ? 0.5 : 1,
...style,
}}
>
{leadingIcon && (
<span style={{ display: "flex", color: "var(--text-disabled)", flex: "0 0 auto" }}>{leadingIcon}</span>
)}
<input
type={type}
value={value}
onChange={onChange}
placeholder={placeholder}
disabled={disabled}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
style={{
flex: 1,
minWidth: 0,
background: "transparent",
border: "none",
outline: "none",
color: "var(--text-primary)",
font: "var(--weight-normal) var(--text-sm)/1 var(--font-sans)",
}}
{...rest}
/>
{trailing && <span style={{ display: "flex", flex: "0 0 auto" }}>{trailing}</span>}
</span>
{error && (
<span style={{ display: "block", marginTop: "5px", font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--status-error)" }}>
{error}
</span>
)}
</label>
);
}
@@ -0,0 +1,9 @@
**Input** — text field on a sunken well. Supports label, leading icon (search), trailing element, and error state.
```jsx
<Input label="Channel name" value={v} onChange={e=>set(e.target.value)} />
<Input leadingIcon={<SearchIcon/>} placeholder="Search channels…" />
<Input label="Number" error="Already in use" value="5.1" />
```
Props: `label`, `leadingIcon`, `trailing`, `error`, `size` (sm·md), `disabled`, `type`.
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
export interface SelectOption {
value: string;
label: string;
}
export interface SelectProps {
label?: string;
value?: string;
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
/** Options as strings or {value,label} objects. */
options?: (string | SelectOption)[];
disabled?: boolean;
size?: "sm" | "md";
fullWidth?: boolean;
style?: React.CSSProperties;
}
/**
* Native-backed select with ChicoryTV chrome and a custom chevron.
*/
export function Select(props: SelectProps): JSX.Element;
+76
View File
@@ -0,0 +1,76 @@
import React from "react";
/**
* Styled select built on a native <select> for accessibility.
*/
export function Select({
label,
value,
onChange,
options = [],
disabled = false,
size = "md",
fullWidth = true,
style = {},
...rest
}) {
const [focus, setFocus] = React.useState(false);
const height = size === "sm" ? "var(--control-h-sm)" : "var(--control-h)";
const opts = options.map((o) => (typeof o === "string" ? { value: o, label: o } : o));
return (
<label style={{ display: "block", width: fullWidth ? "100%" : "auto" }}>
{label && (
<span style={{ display: "block", marginBottom: "6px", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
{label}
</span>
)}
<span
style={{
position: "relative",
display: "flex",
alignItems: "center",
height,
background: "var(--ctv-bg-sunken)",
border: `1px solid ${focus ? "var(--action-primary)" : "var(--border-control)"}`,
borderRadius: "var(--radius-sm)",
boxShadow: focus ? "0 0 0 3px var(--focus-ring)" : "none",
transition: "border-color var(--dur-fast), box-shadow var(--dur-fast)",
opacity: disabled ? 0.5 : 1,
...style,
}}
>
<select
value={value}
onChange={onChange}
disabled={disabled}
onFocus={() => setFocus(true)}
onBlur={() => setFocus(false)}
style={{
appearance: "none",
WebkitAppearance: "none",
flex: 1,
height: "100%",
padding: "0 30px 0 10px",
background: "transparent",
border: "none",
outline: "none",
color: "var(--text-primary)",
font: "var(--weight-normal) var(--text-sm)/1 var(--font-sans)",
cursor: disabled ? "not-allowed" : "pointer",
}}
{...rest}
>
{opts.map((o) => (
<option key={o.value} value={o.value} style={{ background: "var(--ctv-surface-2)", color: "var(--text-primary)" }}>
{o.label}
</option>
))}
</select>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style={{ position: "absolute", right: 9, pointerEvents: "none", color: "var(--text-disabled)" }}>
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</label>
);
}
@@ -0,0 +1,9 @@
**Select** — dropdown built on a native `<select>` for keyboard/a11y, dressed in ChicoryTV chrome.
```jsx
<Select label="Streaming mode"
value={mode} onChange={e=>setMode(e.target.value)}
options={["HLS Segmenter","HLS Direct","MPEG-TS"]} />
```
Options accept plain strings or `{value,label}`. Props: `label`, `size` (sm·md), `disabled`.
+15
View File
@@ -0,0 +1,15 @@
import * as React from "react";
export interface SwitchProps {
checked?: boolean;
onChange?: (next: boolean) => void;
label?: string;
disabled?: boolean;
size?: "sm" | "md";
style?: React.CSSProperties;
}
/**
* Boolean toggle — "Show disabled", "Enabled in EPG", filler visibility.
*/
export function Switch(props: SwitchProps): JSX.Element;
+48
View File
@@ -0,0 +1,48 @@
import React from "react";
/**
* On/off toggle switch.
*/
export function Switch({ checked = false, onChange, disabled = false, label, size = "md", style = {} }) {
const w = size === "sm" ? 30 : 36;
const h = size === "sm" ? 17 : 20;
const knob = h - 5;
const toggle = () => { if (!disabled && onChange) onChange(!checked); };
return (
<label style={{ display: "inline-flex", alignItems: "center", gap: "9px", cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1, ...style }}>
<span
role="switch"
aria-checked={checked}
onClick={toggle}
style={{
position: "relative",
width: w,
height: h,
flex: "0 0 auto",
borderRadius: "var(--radius-pill)",
background: checked ? "var(--action-primary)" : "var(--ctv-surface-3)",
border: `1px solid ${checked ? "transparent" : "var(--border-control)"}`,
transition: "background var(--dur-base) var(--ease-standard)",
}}
>
<span
style={{
position: "absolute",
top: "50%",
left: checked ? `calc(100% - ${knob + 2}px)` : "2px",
transform: "translateY(-50%)",
width: knob,
height: knob,
borderRadius: "50%",
background: "#fff",
boxShadow: "0 1px 2px rgba(0,0,0,0.5)",
transition: "left var(--dur-base) var(--ease-out)",
}}
/>
</span>
{label && <span style={{ font: "var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{label}</span>}
</label>
);
}
@@ -0,0 +1,7 @@
**Switch** — instant on/off toggle for settings and table filters ("Show disabled", "Show filler"). Track turns accent when on.
```jsx
<Switch checked={showDisabled} onChange={setShowDisabled} label="Show disabled" />
```
Props: `checked`, `onChange(next)`, `label`, `size` (sm·md), `disabled`.
@@ -0,0 +1,64 @@
<!-- @dsCard group="Components" viewport="700x330" name="Forms" subtitle="Button, IconButton, Input, Select, Switch, Checkbox" -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../../styles.css">
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="https://unpkg.com/lucide@0.544.0/dist/umd/lucide.min.js"></script>
<script src="../../_ds_bundle.js"></script>
<style>
body { margin: 0; background: var(--surface-app); color: var(--text-primary); font-family: var(--font-sans); }
#root { padding: 22px 24px; }
.row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px 28px; max-width: 620px; }
.lbl { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--text-disabled); margin-bottom: 10px; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { Button, IconButton, Input, Select, Switch, Checkbox } = window.ChicoryTVDesignSystem_eb3b61;
const Ico = ({ n, s = 16 }) => { const r = React.useRef(null); React.useEffect(() => { if (r.current) r.current.innerHTML = ""; const el = window.lucide.createElement(window.lucide.icons[n]); el.setAttribute("width", s); el.setAttribute("height", s); r.current.appendChild(el); }); return <span ref={r} style={{ display: "inline-flex" }} />; };
function Demo() {
const [sw, setSw] = React.useState(true);
const [cb, setCb] = React.useState(true);
return (
<div>
<div className="row">
<Button variant="primary" startIcon={<Ico n="Plus" />}>Add Channel</Button>
<Button variant="secondary" startIcon={<Ico n="Pencil" />}>Edit Numbers</Button>
<Button variant="ghost">Cancel</Button>
<Button variant="danger" startIcon={<Ico n="Trash2" />}>Delete</Button>
<Button variant="primary" loading>Saving</Button>
<Button variant="primary" disabled>Disabled</Button>
</div>
<div className="row">
<IconButton title="Preview" variant="solid"><Ico n="Play" /></IconButton>
<IconButton title="Edit"><Ico n="Pencil" /></IconButton>
<IconButton title="Refresh"><Ico n="RefreshCw" /></IconButton>
<IconButton title="Info"><Ico n="Info" /></IconButton>
<IconButton title="Delete"><Ico n="Trash2" /></IconButton>
<IconButton title="Disabled" disabled><Ico n="Play" /></IconButton>
<span style={{ width: 16 }} />
<Switch checked={sw} onChange={setSw} label="Show disabled" />
<Checkbox checked={cb} onChange={setCb} label="In EPG" />
</div>
<div className="grid">
<Input label="Channel name" value="Retro Cartoons" onChange={() => {}} />
<Select label="Streaming mode" value="HLS Segmenter" onChange={() => {}} options={["HLS Segmenter", "HLS Direct", "MPEG-TS"]} />
<Input leadingIcon={<Ico n="Search" />} placeholder="Search channels…" />
<Input label="Channel number" value="5.1" error="Already in use" onChange={() => {}} />
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<Demo />);
</script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react";
export interface NavItemProps {
/** 1618px stroke icon. */
icon?: React.ReactNode;
label: string;
active?: boolean;
/** Count badge (playout warnings, health errors). */
badge?: React.ReactNode;
badgeTone?: "warn" | "error" | "accent";
href?: string;
onClick?: (e: React.MouseEvent) => void;
style?: React.CSSProperties;
}
/**
* One left-sidebar nav row; active state shows an accent rail + tint.
*/
export function NavItem(props: NavItemProps): JSX.Element;
export interface NavSectionProps {
children?: React.ReactNode;
style?: React.CSSProperties;
}
/** Uppercase eyebrow grouping a set of NavItems. */
export function NavSection(props: NavSectionProps): JSX.Element;
@@ -0,0 +1,55 @@
import React from "react";
/**
* Single left-sidebar navigation item. Compose a list of these.
*/
export function NavItem({ icon = null, label, active = false, badge = null, badgeTone = "warn", onClick, href, style = {} }) {
const [hover, setHover] = React.useState(false);
const Comp = href ? "a" : "button";
const bt = { warn: "var(--status-warn)", error: "var(--status-error)", accent: "var(--action-primary)" }[badgeTone];
return (
<Comp
href={href}
onClick={onClick}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
position: "relative",
display: "flex",
alignItems: "center",
gap: "10px",
width: "100%",
height: "34px",
padding: "0 10px",
border: "none",
borderRadius: "var(--radius-sm)",
textDecoration: "none",
cursor: "pointer",
textAlign: "left",
font: `${active ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-sm)/1 var(--font-sans)`,
color: active ? "var(--text-primary)" : hover ? "var(--text-primary)" : "var(--text-secondary)",
background: active ? "var(--ctv-accent-soft)" : hover ? "var(--ctv-surface-2)" : "transparent",
transition: "background var(--dur-fast) var(--ease-standard), color var(--dur-fast)",
...style,
}}
>
{active && <span style={{ position: "absolute", left: 0, top: 7, bottom: 7, width: 2.5, borderRadius: 2, background: "var(--action-primary)" }} />}
{icon && <span style={{ display: "inline-flex", color: active ? "var(--action-primary)" : "currentColor", flex: "0 0 auto" }}>{icon}</span>}
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
{badge != null && (
<span style={{ minWidth: 17, height: 17, padding: "0 5px", display: "inline-flex", alignItems: "center", justifyContent: "center", borderRadius: "var(--radius-pill)", background: bt, color: badgeTone === "warn" ? "#1A1206" : "#fff", font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-mono)" }}>{badge}</span>
)}
</Comp>
);
}
/**
* Sidebar section label (eyebrow) that groups NavItems.
*/
export function NavSection({ children, style = {} }) {
return (
<div style={{ padding: "14px 10px 6px", font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "var(--tracking-caps)", textTransform: "uppercase", color: "var(--text-disabled)", ...style }}>
{children}
</div>
);
}
@@ -0,0 +1,9 @@
**NavItem / NavSection** — building blocks of the left sidebar. Active item gets an accent rail, tint, and accent icon. `NavSection` is the uppercase group eyebrow. Count badges surface playout warnings / health errors.
```jsx
<NavSection>Scheduling</NavSection>
<NavItem icon={<Ico n="Tv"/>} label="Channels" active />
<NavItem icon={<Ico n="CalendarClock"/>} label="Playouts" badge={3} badgeTone="warn" />
```
Props (NavItem): `icon`, `label`, `active`, `badge`, `badgeTone` (warn·error·accent), `href`/`onClick`.
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
export interface TabItem {
value: string;
label: string;
icon?: React.ReactNode;
count?: number;
}
export interface TabsProps {
/** Strings or {value,label,icon,count}. */
tabs: (string | TabItem)[];
value?: string;
onChange?: (value: string) => void;
style?: React.CSSProperties;
}
/**
* Underlined tab bar for editor sub-views (Channel: General / Streaming / Watermark).
*/
export function Tabs(props: TabsProps): JSX.Element;
@@ -0,0 +1,50 @@
import React from "react";
/**
* Underlined tab bar. Controlled via value/onChange.
*/
export function Tabs({ tabs = [], value, onChange, style = {} }) {
const items = tabs.map((t) => (typeof t === "string" ? { value: t, label: t } : t));
const active = value ?? items[0]?.value;
return (
<div style={{ display: "flex", gap: "2px", borderBottom: "1px solid var(--border-hairline)", ...style }}>
{items.map((t) => {
const on = t.value === active;
return <Tab key={t.value} tab={t} on={on} onChange={onChange} />;
})}
</div>
);
}
function Tab({ tab, on, onChange }) {
const [hover, setHover] = React.useState(false);
return (
<button
type="button"
onClick={() => onChange && onChange(tab.value)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
position: "relative",
display: "inline-flex",
alignItems: "center",
gap: "7px",
height: "34px",
padding: "0 12px",
border: "none",
background: "transparent",
cursor: "pointer",
font: `${on ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-sm)/1 var(--font-sans)`,
color: on ? "var(--text-primary)" : hover ? "var(--text-primary)" : "var(--text-secondary)",
transition: "color var(--dur-fast)",
}}
>
{tab.icon && <span style={{ display: "inline-flex", color: on ? "var(--action-primary)" : "currentColor" }}>{tab.icon}</span>}
{tab.label}
{tab.count != null && (
<span style={{ font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)", background: "var(--ctv-surface-3)", padding: "2px 5px", borderRadius: "var(--radius-xs)" }}>{tab.count}</span>
)}
<span style={{ position: "absolute", left: 6, right: 6, bottom: -1, height: 2, borderRadius: 2, background: on ? "var(--action-primary)" : "transparent", transition: "background var(--dur-fast)" }} />
</button>
);
}
@@ -0,0 +1,8 @@
**Tabs** — underlined tab bar for editor sub-views and detail panes (Channel → General / Streaming / Watermark; Playout → Detail / Log). Active tab shows an accent underline.
```jsx
<Tabs value={tab} onChange={setTab}
tabs={[{value:"general",label:"General"},{value:"streaming",label:"Streaming"},{value:"items",label:"Items",count:12}]} />
```
Props: `tabs` (string | {value,label,icon,count}), `value`, `onChange`.
@@ -0,0 +1,53 @@
<!-- @dsCard group="Components" viewport="700x260" name="Navigation" subtitle="NavItem sidebar, Tabs" -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300..700&family=Geist+Mono:wght@400..600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../../styles.css">
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="https://unpkg.com/lucide@0.544.0/dist/umd/lucide.min.js"></script>
<script src="../../_ds_bundle.js"></script>
<style>
body { margin: 0; background: var(--surface-app); color: var(--text-primary); font-family: var(--font-sans); }
#root { padding: 20px 24px; display: flex; gap: 28px; align-items: flex-start; }
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { NavItem, NavSection, Tabs } = NS;
const Ico = ({ n, s = 16 }) => { const r = React.useRef(null); React.useEffect(() => { if (r.current){ r.current.innerHTML=""; const el = window.lucide.createElement(window.lucide.icons[n]); el.setAttribute("width", s); el.setAttribute("height", s); r.current.appendChild(el);} }); return <span ref={r} style={{ display: "inline-flex" }} />; };
function Demo() {
const [tab, setTab] = React.useState("streaming");
return (
<React.Fragment>
<div style={{ width: 216, background: "var(--surface-card)", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", padding: 8 }}>
<NavItem icon={<Ico n="LayoutDashboard" />} label="Dashboard" />
<NavItem icon={<Ico n="Tv" />} label="Channels" active />
<NavItem icon={<Ico n="CalendarClock" />} label="Schedules" />
<NavItem icon={<Ico n="ListVideo" />} label="Playouts" badge={3} badgeTone="warn" />
<NavSection>Media</NavSection>
<NavItem icon={<Ico n="Library" />} label="Libraries" />
<NavItem icon={<Ico n="FolderTree" />} label="Collections" />
<NavItem icon={<Ico n="Settings" />} label="Settings" />
</div>
<div style={{ flex: 1, maxWidth: 380 }}>
<Tabs value={tab} onChange={setTab} tabs={[
{ value: "general", label: "General" },
{ value: "streaming", label: "Streaming" },
{ value: "items", label: "Schedule items", count: 12 },
]} />
</div>
</React.Fragment>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<Demo />);
</script>
</body>
</html>
@@ -0,0 +1,619 @@
// Channel Builder — GROUND-UP replacement for the four-editor create chain
// (Channel -> Schedule -> Schedule Items -> Playout). One library-to-lineup screen.
// Left: searchable library browser. Center: the channel lineup (add by double-click
// or drag; drag to reorder). Right: friendly options + Channel Template + Advanced.
// Behind the scenes "Create" builds Channel + Collection + Schedule + Items + Playout.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Input, Select, Switch, Badge, ChannelLogo, Tooltip } = NS;
const Ico = window.Ico;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" };
// ---- Mock library (shows / movies / music / docs across libraries) --------
const LIB = [
{ id: "s1", title: "Looney Tunes", type: "Show", library: "Cartoons", sub: "247 shorts", min: 96 },
{ id: "s2", title: "Tom & Jerry", type: "Show", library: "Cartoons", sub: "164 shorts", min: 72 },
{ id: "s3", title: "Popeye", type: "Show", library: "Cartoons", sub: "108 shorts", min: 54 },
{ id: "s4", title: "The Flintstones", type: "Show", library: "Cartoons", sub: "166 episodes", min: 84 },
{ id: "s5", title: "Scooby-Doo", type: "Show", library: "Cartoons", sub: "97 episodes", min: 60 },
{ id: "s6", title: "Pink Panther", type: "Show", library: "Cartoons", sub: "124 shorts", min: 48 },
{ id: "m1", title: "Blade Runner", type: "Movie", library: "Movies", sub: "1982 · Sci-Fi", min: 117 },
{ id: "m2", title: "Alien", type: "Movie", library: "Movies", sub: "1979 · Horror", min: 117 },
{ id: "m3", title: "The Thing", type: "Movie", library: "Movies", sub: "1982 · Horror", min: 109 },
{ id: "m4", title: "Metropolis", type: "Movie", library: "Movies", sub: "1927 · Sci-Fi", min: 153 },
{ id: "m5", title: "Nosferatu", type: "Movie", library: "Movies", sub: "1922 · Horror", min: 94 },
{ id: "t1", title: "The Twilight Zone", type: "Show", library: "TV Shows", sub: "156 episodes", min: 51 },
{ id: "t2", title: "Star Trek", type: "Show", library: "TV Shows", sub: "79 episodes", min: 50 },
{ id: "t3", title: "Columbo", type: "Show", library: "TV Shows", sub: "69 episodes", min: 94 },
{ id: "t4", title: "Cheers", type: "Show", library: "TV Shows", sub: "275 episodes", min: 24 },
{ id: "u1", title: "80s Synthpop", type: "Music", library: "Music Videos", sub: "212 videos", min: 45 },
{ id: "u2", title: "90s Alt Rock", type: "Music", library: "Music Videos", sub: "188 videos", min: 45 },
{ id: "u3", title: "Chart Countdown", type: "Music", library: "Music Videos", sub: "Top 40", min: 60 },
{ id: "d1", title: "Blue Planet", type: "Doc", library: "Documentaries", sub: "8 episodes", min: 50 },
{ id: "d2", title: "Planet Earth", type: "Doc", library: "Documentaries", sub: "11 episodes", min: 50 },
{ id: "d3", title: "Cosmos", type: "Doc", library: "Documentaries", sub: "13 episodes", min: 60 },
];
const LIBRARIES = ["All", "Cartoons", "Movies", "TV Shows", "Music Videos", "Documentaries"];
const TYPE_ICON = { Show: "Tv", Movie: "Film", Music: "Music", Doc: "Radio", Multi: "Layers", Smart: "Sparkles", Manual: "FolderTree", Rerun: "Repeat" };
// Collections — curated groups (Manual / Smart / Multi / Rerun) addable as a
// single lineup entry, so the builder isn't limited to raw library items.
const COLLECTIONS = [
{ id: "c1", title: "Prime Time Cartoons", type: "Multi", library: "Collection", sub: "Multi · 62 items", min: 180, coll: true },
{ id: "c2", title: "Classic Sci-Fi", type: "Smart", library: "Collection", sub: "Smart · rating \u2265 8", min: 240, coll: true },
{ id: "c3", title: "Retro Station IDs", type: "Manual", library: "Collection", sub: "Manual · 24 items", min: 12, coll: true },
{ id: "c4", title: "Saturday Morning", type: "Rerun", library: "Collection", sub: "Rerun block · weekly",min: 120, coll: true },
{ id: "c5", title: "Halloween Marathon", type: "Manual", library: "Collection", sub: "Manual · 9 films", min: 900, coll: true },
];
// Channel Templates — bundle the clunky technical config; one is pre-selected.
const TEMPLATES = [
{ id: "std", name: "Standard", builtin: true, desc: "General-purpose 1080p H.264, retro bumpers.", shuffle: false, always: true,
sets: ["1080p H.264", "HLS Segmenter", "Sequential", "Always on", "Pre + post filler"] },
{ id: "music", name: "Music videos", builtin: true, desc: "Continuous rotation, no fillers, direct stream.", shuffle: true, always: true,
sets: ["720p H.264", "HLS Direct", "Shuffle", "Always on", "No filler"] },
{ id: "film", name: "Movie night", builtin: true, desc: "Film-grain HEVC, mid-roll ad breaks.", shuffle: false, always: false,
sets: ["1080p HEVC", "MPEG-TS", "Sequential", "On-demand", "Mid-roll ads"] },
{ id: "retro", name: "Retro Saturday AM", builtin: false, desc: "Your saved cartoon-block template.", shuffle: true, always: true,
sets: ["1080p H.264", "HLS Segmenter", "Shuffle", "Always on", "Bumpers + ads"] },
];
// Advanced (~30-field) override set, grouped. Values shown as template-inherited.
const ADV = [
{ group: "Streaming", fields: [
["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920×1080"],
["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"],
]},
{ group: "Filler", fields: [
["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"],
["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"],
]},
{ group: "Playback", fields: [
["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"],
["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"],
]},
{ group: "Behavior", fields: [
["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"],
["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"],
]},
];
// deterministic low-chroma poster hue from a title
function hueOf(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return h; }
function fmtDur(totalMin) {
const h = Math.floor(totalMin / 60), m = totalMin % 60;
if (h && m) return h + "h " + String(m).padStart(2, "0") + "m";
if (h) return h + "h";
return m + "m";
}
// ---- Overlay scroll region — hides native bar (no reflow when it appears),
// shows a subtle thumb only while scrolling, fading out when idle. ------
function ScrollArea({ children, innerStyle, scrollerRef }) {
const local = React.useRef(null);
const timer = React.useRef(null);
const [thumb, setThumb] = React.useState(null);
const setRef = (n) => { local.current = n; if (scrollerRef) scrollerRef.current = n; };
const update = () => {
const el = local.current; if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
if (scrollHeight <= clientHeight + 2) { setThumb(null); return; }
const track = clientHeight - 8;
const h = Math.max(28, track * clientHeight / scrollHeight);
const top = 4 + (track - h) * (scrollTop / (scrollHeight - clientHeight));
setThumb({ top, h });
clearTimeout(timer.current);
timer.current = setTimeout(() => setThumb((t) => (t ? { ...t, hidden: true } : null)), 850);
};
React.useEffect(() => { const id = setTimeout(update, 60); return () => { clearTimeout(id); clearTimeout(timer.current); }; }, []);
return (
<div style={{ position: "relative", flex: 1, minHeight: 0, minWidth: 0 }}>
<div ref={setRef} onScroll={update} className="ctv-hide-sb"
style={{ height: "100%", overflowY: "auto", overflowX: "hidden", scrollbarWidth: "none", scrollBehavior: "smooth", ...innerStyle }}>
{children}
</div>
{thumb && (
<div style={{ position: "absolute", top: thumb.top, right: 2, width: 4, height: thumb.h, borderRadius: 3,
background: "var(--text-secondary)", opacity: thumb.hidden ? 0 : 0.5,
transition: "opacity 260ms var(--ease-standard, ease)", pointerEvents: "none" }} />
)}
</div>
);
}
// ---- Segmented control — iOS-style sliding active highlight ---------------
function Segmented({ value, options, onChange, style }) {
const idx = Math.max(0, options.findIndex((o) => o.value === value));
return (
<div style={{ position: "relative", display: "grid", gridAutoFlow: "column", gridAutoColumns: "1fr", padding: 2, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", ...style }}>
<div style={{ position: "absolute", top: 2, bottom: 2, left: 2, width: `calc((100% - 4px) / ${options.length})`, transform: `translateX(${idx * 100}%)`, background: "var(--ctv-surface-3)", borderRadius: "var(--radius-xs)", boxShadow: "var(--shadow-sm)", transition: "transform 300ms cubic-bezier(0.16,1,0.3,1)", pointerEvents: "none" }} />
{options.map((o) => {
const on = o.value === value;
return (
<button key={o.value} type="button" title={o.title || o.label} onClick={() => onChange(o.value)}
style={{ position: "relative", zIndex: 1, display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "5px 10px", border: "none", background: "transparent", cursor: "pointer",
color: on ? "var(--text-primary)" : "var(--text-secondary)", transition: "color 220ms cubic-bezier(0.16,1,0.3,1)", font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)" }}>
{o.icon && <Ico n={o.icon} s={13} />}{o.label}
</button>
);
})}
</div>
);
}
// ---- Poster placeholder (striped, tinted, mono meta) ----------------------
function Poster({ item, w, h, mini }) {
const hue = hueOf(item.title);
return (
<div style={{
position: "relative", width: w, height: h, flex: "0 0 auto", overflow: "hidden",
borderRadius: "var(--radius-sm)", border: "1px solid var(--border-hairline)",
background: `linear-gradient(160deg, hsl(${hue} 24% 17%), hsl(${hue} 26% 12%))`,
}}>
<div style={{ position: "absolute", inset: 0, backgroundImage: `repeating-linear-gradient(135deg, hsl(${hue} 22% 10% / .55) 0 1px, transparent 1px 9px)` }} />
<div style={{ position: "absolute", top: mini ? 4 : 7, left: mini ? 4 : 7, display: "inline-flex", color: `hsl(${hue} 30% 68%)` }}>
<Ico n={TYPE_ICON[item.type] || "Film"} s={mini ? 12 : 15} />
</div>
{!mini && (
<div style={{ position: "absolute", left: 0, right: 0, bottom: 0, padding: "18px 8px 8px", background: "linear-gradient(to top, rgba(11,13,17,.92), transparent)" }}>
<div style={{ font: "var(--weight-semibold) var(--text-xs)/1.15 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.title}</div>
<div style={{ marginTop: 3, ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-secondary)" }}>{item.sub}</div>
</div>
)}
</div>
);
}
// ---- Library card ----------------------------------------------------------
function LibCard({ item, added, compact, onAdd, onDragStart, onDragEnd }) {
if (compact) {
return (
<div draggable onDragStart={onDragStart} onDragEnd={onDragEnd} onDoubleClick={onAdd} title="Double-click or drag to add" className="ctv-press"
style={{ display: "flex", alignItems: "center", gap: 10, padding: "6px 8px", borderRadius: "var(--radius-sm)", cursor: "grab",
background: added ? "color-mix(in srgb, var(--action-primary) 14%, transparent)" : "var(--surface-card)", border: `1px solid ${added ? "color-mix(in srgb, var(--action-primary) 35%, transparent)" : "var(--border-hairline)"}` }}>
<Poster item={item} w={30} h={40} mini />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-xs)/1.15 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.title}</div>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{item.type} · <span style={mono}>{item.sub}</span></div>
</div>
<IconButton size="sm" title={added ? "Added" : "Add to lineup"} onClick={onAdd}>
<Ico n={added ? "Check" : "Plus"} s={15} color={added ? "var(--action-primary)" : undefined} />
</IconButton>
</div>
);
}
return (
<div draggable onDragStart={onDragStart} onDragEnd={onDragEnd} onDoubleClick={onAdd} title="Double-click or drag to add" className="ctv-lift"
style={{ position: "relative", cursor: "grab" }}>
<div style={{ position: "relative", borderRadius: "var(--radius-sm)", boxShadow: added ? "0 0 0 2px var(--action-primary)" : "none" }}>
<Poster item={item} w="100%" h={150} />
{added && (
<div style={{ position: "absolute", top: 6, right: 6, width: 22, height: 22, borderRadius: "50%", background: "var(--action-primary)", color: "var(--text-on-accent)", display: "flex", alignItems: "center", justifyContent: "center", boxShadow: "var(--shadow-sm)", animation: "ctv-pop 260ms cubic-bezier(0.16,1,0.3,1)" }}>
<Ico n="Check" s={14} />
</div>
)}
</div>
</div>
);
}
// ---- Lineup row ------------------------------------------------------------
function LineupRow({ item, index, shuffle, dragging, isOver, onRemove, dragProps }) {
return (
<div {...dragProps} className="ctv-lift"
style={{ display: "flex", alignItems: "center", gap: 11, padding: "9px 10px", borderRadius: "var(--radius-md)",
background: "var(--surface-card)", border: "1px solid var(--border-hairline)",
boxShadow: isOver ? "0 -2px 0 var(--action-primary)" : "none", opacity: dragging ? 0.35 : 1,
transition: "opacity var(--dur-fast)" }}>
<span style={{ display: "inline-flex", cursor: "grab", color: "var(--text-faint)", flex: "0 0 auto" }} title="Drag to reorder"><Ico n="GripVertical" s={16} /></span>
<span style={{ width: 22, flex: "0 0 auto", textAlign: "center" }}>
{shuffle
? <Ico n="Shuffle" s={13} color="var(--text-disabled)" />
: <span style={{ ...mono, font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)", color: "var(--text-secondary)" }}>{index + 1}</span>}
</span>
<Poster item={item} w={34} h={46} mini />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.title}</div>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{item.type} · {item.library}</div>
</div>
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)", flex: "0 0 auto" }}>{fmtDur(item.min)}</span>
<IconButton size="sm" title="Remove" onClick={onRemove}><Ico n="X" s={15} /></IconButton>
</div>
);
}
// ---- Channel image dropzone + live logo/bug preview -----------------------
function ImagePanel({ logoSrc, setLogoSrc, name }) {
const [sep, setSep] = React.useState(false);
const [over, setOver] = React.useState(false);
const fileRef = React.useRef(null);
const read = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => setLogoSrc(r.result); r.readAsDataURL(file); };
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div
onDragOver={(e) => { e.preventDefault(); setOver(true); }}
onDragLeave={() => setOver(false)}
onDrop={(e) => { e.preventDefault(); setOver(false); read(e.dataTransfer.files && e.dataTransfer.files[0]); }}
onClick={() => fileRef.current && fileRef.current.click()}
style={{ display: "flex", gap: 12, padding: 12, borderRadius: "var(--radius-sm)", cursor: "pointer",
background: "var(--ctv-bg-sunken)", border: `1px dashed ${over ? "var(--ctv-accent)" : "var(--border-control)"}` }}>
<ChannelLogo name={name || "New Channel"} src={logoSrc} size={52} radius="var(--radius-sm)" />
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", justifyContent: "center", gap: 3 }}>
<div style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{logoSrc ? "Channel image set" : "Drop channel image"}</div>
<div style={{ font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>Used as both the guide logo and the on-screen bug.</div>
</div>
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => read(e.target.files && e.target.files[0])} />
</div>
{/* live preview: listing logo + on-screen bug */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10 }}>
<div style={{ padding: 10, borderRadius: "var(--radius-sm)", background: "var(--surface-card)", border: "1px solid var(--border-hairline)" }}>
<div style={{ ...eyebrow, marginBottom: 8 }}>Guide logo</div>
<div style={{ display: "flex", alignItems: "center", gap: 9 }}>
<ChannelLogo name={name || "New Channel"} src={logoSrc} size={30} radius="var(--radius-xs)" />
<div style={{ minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-2xs)/1.1 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{name || "New Channel"}</div>
<div style={{ ...mono, font: "9px/1 var(--font-mono)", color: "var(--text-disabled)" }}>listing</div>
</div>
</div>
</div>
<div style={{ padding: 10, borderRadius: "var(--radius-sm)", background: "var(--surface-card)", border: "1px solid var(--border-hairline)" }}>
<div style={{ ...eyebrow, marginBottom: 8 }}>On-screen bug</div>
<div style={{ position: "relative", height: 42, borderRadius: "var(--radius-xs)", overflow: "hidden", background: "linear-gradient(160deg,#1a1d24,#0e1014)", backgroundImage: "repeating-linear-gradient(135deg, rgba(255,255,255,.03) 0 1px, transparent 1px 7px)" }}>
<div style={{ position: "absolute", bottom: 5, right: 5, opacity: 0.9 }}>
<ChannelLogo name={name || "New Channel"} src={logoSrc} size={22} radius="3px" />
</div>
</div>
</div>
</div>
<button type="button" onClick={() => setSep((s) => !s)}
style={{ alignSelf: "flex-start", display: "inline-flex", alignItems: "center", gap: 5, background: "none", border: "none", cursor: "pointer", padding: "2px 0", font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<Ico n={sep ? "ChevronDown" : "ChevronRight"} s={13} />Set logo & bug separately
</button>
{sep && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 10, padding: 10, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
<Button size="sm" variant="secondary" startIcon={<Ico n="Image" s={14} />} onClick={() => fileRef.current && fileRef.current.click()}>Logo image</Button>
<Button size="sm" variant="secondary" startIcon={<Ico n="Image" s={14} />} onClick={() => fileRef.current && fileRef.current.click()}>Bug image</Button>
</div>
)}
</div>
);
}
// ---- Advanced disclosure — one-click Override (edit) or View defaults ------
function Advanced({ templateName, scrollerRef }) {
const [mode, setMode] = React.useState("closed"); // closed | view | override
const [vals, setVals] = React.useState(() => { const o = {}; ADV.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; });
const override = mode === "override";
const count = ADV.reduce((n, g) => n + g.fields.length, 0);
const fieldsRef = React.useRef(null);
const rootRef = React.useRef(null);
const [maxH, setMaxH] = React.useState(0);
React.useLayoutEffect(() => {
if (mode === "closed") setMaxH(0);
else if (fieldsRef.current) setMaxH(fieldsRef.current.scrollHeight);
}, [mode]);
const toggle = (m) => {
const next = mode === m ? "closed" : m;
setMode(next);
if (next !== "closed" && scrollerRef && scrollerRef.current && rootRef.current) {
const sc = scrollerRef.current;
requestAnimationFrame(() => {
const target = sc.scrollTop + rootRef.current.getBoundingClientRect().top - sc.getBoundingClientRect().top - 20;
sc.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
});
}
};
const tabBtn = (active) => ({
display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "8px 10px",
borderRadius: "var(--radius-sm)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)",
background: active ? "color-mix(in srgb, var(--action-primary) 14%, transparent)" : "var(--ctv-surface-2)",
color: active ? "var(--action-primary)" : "var(--text-secondary)",
border: `1px solid ${active ? "color-mix(in srgb, var(--action-primary) 38%, transparent)" : "var(--border-hairline)"}`,
transition: "background 200ms cubic-bezier(0.16,1,0.3,1), color 200ms cubic-bezier(0.16,1,0.3,1), border-color 200ms cubic-bezier(0.16,1,0.3,1)",
});
return (
<div ref={rootRef} style={{ borderTop: "1px solid var(--border-hairline)", paddingTop: 14 }}>
<div style={{ display: "flex", alignItems: "center", gap: 9, marginBottom: 10 }}>
<Ico n="SlidersHorizontal" s={16} color="var(--text-secondary)" />
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Advanced</span>
<span style={{ flex: 1 }} />
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{count} fields · {templateName}</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button type="button" onClick={() => toggle("override")} style={tabBtn(override)}>
<Ico n="SquarePen" s={14} />{override ? "Overriding" : "Override settings"}
</button>
<button type="button" onClick={() => toggle("view")} style={tabBtn(mode === "view")}>
<Ico n="Eye" s={14} />View defaults
</button>
</div>
<div style={{ overflow: "hidden", maxHeight: maxH, transition: "max-height 340ms cubic-bezier(0.16,1,0.3,1)" }}>
<div ref={fieldsRef} style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 14 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>
<Ico n="Info" s={13} color={override ? "var(--action-primary)" : "var(--text-disabled)"} />
{override ? "Editing these overrides the template for this channel only." : `Read-only — inherited from the ${templateName} template. Turn on Override to edit.`}
</div>
{ADV.map((g) => (
<div key={g.group}>
<div style={{ ...eyebrow, marginBottom: 8 }}>{g.group}</div>
{override ? (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{g.fields.map(([k]) => (
<Input key={k} size="sm" label={k} value={vals[k]} onChange={(e) => setVals((s) => ({ ...s, [k]: e.target.value }))} />
))}
</div>
) : (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
{g.fields.map(([k, v]) => (
<div key={k} style={{ padding: "7px 9px", borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", opacity: 0.72 }}>
<div style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{k}</div>
<div style={{ marginTop: 3, font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)", ...(/\d/.test(v) ? mono : {}) }}>{v}</div>
</div>
))}
</div>
)}
</div>
))}
</div>
</div>
</div>
);
}
// ---- Main ------------------------------------------------------------------
function ChannelBuilder() {
const [q, setQ] = React.useState("");
const [libFilter, setLibFilter] = React.useState("All");
const [compact, setCompact] = React.useState(false);
const [lineup, setLineup] = React.useState(() => ["s1", "s2", "s3", "s4"].map((id) => LIB.find((x) => x.id === id)));
const [name, setName] = React.useState("Retro Cartoons");
const [number, setNumber] = React.useState("13.1");
const [shuffle, setShuffle] = React.useState(false);
const [always, setAlways] = React.useState(true);
const [logoSrc, setLogoSrc] = React.useState(null);
const [tpl, setTpl] = React.useState("std");
const [tplOpen, setTplOpen] = React.useState(false);
const [source, setSource] = React.useState("library"); // library | collections
// drag state
const [dragLib, setDragLib] = React.useState(null); // library item id being dragged
const [dragRow, setDragRow] = React.useState(null); // lineup index being dragged
const [overRow, setOverRow] = React.useState(null);
const railScrollerRef = React.useRef(null);
const addedIds = new Set(lineup.map((i) => i.id));
const pool = source === "collections" ? COLLECTIONS : LIB;
const filtered = pool.filter((i) =>
(source === "collections" || libFilter === "All" || i.library === libFilter) &&
(!q || i.title.toLowerCase().includes(q.toLowerCase())));
const add = (item) => setLineup((l) => (l.some((x) => x.id === item.id) ? l : [...l, item]));
const removeAt = (idx) => setLineup((l) => l.filter((_, i) => i !== idx));
const totalMin = lineup.reduce((n, i) => n + i.min, 0);
const template = TEMPLATES.find((t) => t.id === tpl);
const canCreate = name.trim() && lineup.length > 0;
// reorder / insert on drop at target index
const dropAt = (targetIdx) => {
if (dragRow != null) {
setLineup((l) => { const n = l.slice(); const [m] = n.splice(dragRow, 1); n.splice(targetIdx, 0, m); return n; });
} else if (dragLib != null) {
const item = [...LIB, ...COLLECTIONS].find((x) => x.id === dragLib);
if (item && !addedIds.has(item.id)) setLineup((l) => { const n = l.slice(); n.splice(targetIdx, 0, item); return n; });
}
setDragLib(null); setDragRow(null); setOverRow(null);
};
const dropEnd = () => {
if (dragLib != null) { const item = [...LIB, ...COLLECTIONS].find((x) => x.id === dragLib); if (item) add(item); }
setDragLib(null); setDragRow(null); setOverRow(null);
};
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* builder toolbar */}
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-accent-soft)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="Plus" s={19} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>New Channel</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Build from your library <span style={mono}>{lineup.length}</span> in lineup · <span style={mono}>{fmtDur(totalMin)}</span></div>
</div>
<Button variant="ghost">Cancel</Button>
<Tooltip placement="bottom" label={canCreate ? "Creates the channel, schedule & playout" : "Name the channel and add at least one item"}>
<Button variant="primary" disabled={!canCreate} startIcon={<Ico n="Check" s={15} />}>Create Channel</Button>
</Tooltip>
</div>
{/* body — library | lineup | settings */}
<div style={{ flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: "minmax(0,1.2fr) minmax(0,1fr) 304px", gap: 0 }}>
{/* LEFT — library browser */}
<section style={{ display: "flex", flexDirection: "column", minHeight: 0, minWidth: 0, overflow: "hidden", borderRight: "1px solid var(--border-hairline)" }}>
<div style={{ padding: "14px 16px 10px", display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", rowGap: 8 }}>
<Segmented value={source} onChange={setSource} options={[{ value: "library", label: "Library", icon: "Library" }, { value: "collections", label: "Collections", icon: "FolderTree" }]} />
<span style={{ flex: 1 }} />
<Segmented value={compact ? "list" : "grid"} onChange={(v) => setCompact(v === "list")} options={[{ value: "grid", icon: "LayoutGrid", title: "Poster grid" }, { value: "list", icon: "Rows3", title: "Compact list" }]} />
</div>
<Input size="sm" value={q} onChange={(e) => setQ(e.target.value)} leadingIcon={<Ico n="Search" s={15} />} placeholder={source === "collections" ? "Search collections\u2026" : "Search shows & movies\u2026"} />
{source === "library" && (
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{LIBRARIES.map((l) => {
const on = libFilter === l;
return (
<button key={l} type="button" onClick={() => setLibFilter(l)} className="ctv-press"
style={{ padding: "4px 10px", borderRadius: "var(--radius-pill)", cursor: "pointer",
font: "var(--weight-medium) var(--text-2xs)/1.4 var(--font-sans)",
background: on ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)",
color: on ? "var(--ctv-accent)" : "var(--text-secondary)",
border: `1px solid ${on ? "rgba(224,138,60,.35)" : "var(--border-hairline)"}` }}>{l}</button>
);
})}
</div>
)}
</div>
<ScrollArea innerStyle={{ padding: "4px 16px 16px" }}>
{filtered.length === 0 ? (
<div style={{ padding: "40px 0", textAlign: "center", font: "var(--text-xs)/1.5 var(--font-sans)", color: "var(--text-disabled)" }}>No titles match.</div>
) : compact ? (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{filtered.map((item) => (
<LibCard key={item.id} item={item} compact added={addedIds.has(item.id)} onAdd={() => add(item)}
onDragStart={() => setDragLib(item.id)} onDragEnd={dropEnd} />
))}
</div>
) : (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(104px,1fr))", gap: 10 }}>
{filtered.map((item) => (
<LibCard key={item.id} item={item} added={addedIds.has(item.id)} onAdd={() => add(item)}
onDragStart={() => setDragLib(item.id)} onDragEnd={dropEnd} />
))}
</div>
)}
</ScrollArea>
</section>
{/* CENTER — lineup */}
<section style={{ display: "flex", flexDirection: "column", minHeight: 0, minWidth: 0, overflow: "hidden", borderRight: "1px solid var(--border-hairline)" }}
onDragOver={(e) => { if (dragLib != null) e.preventDefault(); }}
onDrop={() => { if (dragLib != null) dropEnd(); }}>
<div style={{ padding: "14px 16px 10px", display: "flex", alignItems: "center", gap: 10 }}>
<span style={eyebrow}>Lineup</span>
<Badge tone={shuffle ? "accent" : "neutral"}><span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}><Ico n={shuffle ? "Shuffle" : "ListOrdered"} s={11} />{shuffle ? "Shuffled" : "Sequential"}</span></Badge>
<span style={{ flex: 1 }} />
{lineup.length > 0 && <button type="button" onClick={() => setLineup([])} style={{ background: "none", border: "none", cursor: "pointer", font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Clear</button>}
</div>
<ScrollArea innerStyle={{ padding: "4px 16px 16px" }}>
{lineup.length === 0 ? (
<div style={{ height: "100%", minHeight: 220, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 12, textAlign: "center",
border: `1px dashed ${dragLib != null ? "var(--ctv-accent)" : "var(--border-control)"}`, borderRadius: "var(--radius-md)",
background: dragLib != null ? "var(--ctv-accent-soft)" : "transparent", color: "var(--text-disabled)", padding: 24 }}>
<Ico n="ListVideo" s={26} />
<div style={{ font: "var(--text-sm)/1.5 var(--font-sans)", maxWidth: 240 }}>This channel has no content yet. Double-click or drag titles from the library to build the lineup.</div>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
{lineup.map((item, idx) => (
<LineupRow key={item.id + "-" + idx} item={item} index={idx} shuffle={shuffle}
dragging={dragRow === idx} isOver={overRow === idx && (dragRow !== idx)}
onRemove={() => removeAt(idx)}
dragProps={{
draggable: true,
onDragStart: () => setDragRow(idx),
onDragEnd: () => { setDragRow(null); setOverRow(null); },
onDragOver: (e) => { e.preventDefault(); setOverRow(idx); },
onDrop: (e) => { e.stopPropagation(); dropAt(idx); },
}} />
))}
<div style={{ marginTop: 4, padding: "10px 12px", borderRadius: "var(--radius-md)", border: "1px dashed var(--border-hairline)", display: "flex", alignItems: "center", gap: 8, color: "var(--text-disabled)", font: "var(--text-2xs)/1 var(--font-sans)" }}>
<Ico n="Plus" s={14} />Double-click or drag more titles to add
</div>
</div>
)}
</ScrollArea>
</section>
{/* RIGHT — friendly options + template + advanced */}
<aside style={{ position: "relative", minWidth: 0, overflow: "hidden", display: "flex", flexDirection: "column", background: "var(--surface-card)" }}>
<ScrollArea scrollerRef={railScrollerRef} innerStyle={{ padding: "14px 16px 24px", display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<Input label="Channel name" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Retro Cartoons" />
<Input label="Number" value={number} onChange={(e) => setNumber(e.target.value)}
trailing={<span style={{ ...mono, font: "9px/1 var(--font-mono)", padding: "2px 5px", borderRadius: "3px", background: "var(--ctv-accent-soft)", color: "var(--ctv-accent)" }}>AUTO</span>} />
</div>
<ImagePanel logoSrc={logoSrc} setLogoSrc={setLogoSrc} name={name} />
{/* friendly toggles */}
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<label onClick={() => setShuffle((s) => !s)} style={{ display: "flex", alignItems: "flex-start", gap: 11, padding: "10px 0", cursor: "pointer" }}>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>Shuffle</span>
{shuffle !== template.shuffle && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "2px 6px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)" }}>overrides template</span>}
</div>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>{shuffle ? "Plays in a random order." : "Plays in sequence (chronological)."}</div>
</div>
<Switch checked={shuffle} onChange={setShuffle} />
</label>
<div style={{ height: 1, background: "var(--border-hairline)" }} />
<label onClick={() => setAlways((s) => !s)} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer",
background: always ? "var(--ctv-live-soft)" : "transparent", border: `1px solid ${always ? "var(--ctv-live)" : "transparent"}`, transition: "background 240ms cubic-bezier(0.16,1,0.3,1), border-color 240ms cubic-bezier(0.16,1,0.3,1)" }}>
<span style={{ display: "inline-flex", marginTop: 1, color: "var(--ctv-live)", flex: "0 0 auto" }}><Ico n="Radio" s={16} /></span>
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>Always playing</span>
{always !== template.always && <span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", padding: "2px 6px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)" }}>overrides template</span>}
{always && (
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, padding: "2px 7px", borderRadius: "var(--radius-pill)", background: "var(--ctv-live-soft)", color: "var(--ctv-live)", font: "var(--weight-semibold) 9px/1.4 var(--font-sans)", letterSpacing: ".06em" }}>
<span style={{ width: 5, height: 5, borderRadius: "50%", background: "var(--ctv-live)" }} />LIVE
</span>
)}
</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.35 var(--font-sans)", color: "var(--text-secondary)" }}>Like live TV advances on schedule even when nobody is watching.</div>
</div>
<Switch checked={always} onChange={setAlways} />
</label>
</div>
{/* Channel Template */}
<div>
<div style={{ ...eyebrow, marginBottom: 8 }}>Channel Template</div>
<button type="button" onClick={() => setTplOpen((o) => !o)} className="ctv-press"
style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left",
background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-control)" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 30, height: 30, borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: "var(--ctv-accent)", flex: "0 0 auto" }}><Ico n="SlidersHorizontal" s={15} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{template.name}</span>
{!template.builtin && <Badge tone="neutral">Custom</Badge>}
</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{template.desc}</div>
</div>
<Ico n={tplOpen ? "ChevronUp" : "ChevronDown"} s={15} color="var(--text-disabled)" />
</button>
{tplOpen && (
<div style={{ marginTop: 6, display: "flex", flexDirection: "column", gap: 4, padding: 4, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
{TEMPLATES.map((t) => {
const on = t.id === tpl;
return (
<button key={t.id} type="button" className="ctv-press" onClick={() => { setTpl(t.id); setTplOpen(false); setShuffle(t.shuffle); setAlways(t.always); }}
style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 9px", borderRadius: "var(--radius-xs)", cursor: "pointer", textAlign: "left",
background: on ? "color-mix(in srgb, var(--action-primary) 14%, transparent)" : "transparent", border: "none" }}>
<Ico n={on ? "CircleCheck" : "Circle"} s={15} color={on ? "var(--action-primary)" : "var(--text-disabled)"} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{t.name}</span>
{!t.builtin && <Badge tone="neutral">Custom</Badge>}
</div>
<div style={{ marginTop: 2, font: "var(--text-2xs)/1.3 var(--font-sans)", color: "var(--text-secondary)" }}>{t.desc}</div>
</div>
</button>
);
})}
<button type="button" style={{ display: "flex", alignItems: "center", gap: 7, padding: "8px 9px", borderRadius: "var(--radius-xs)", cursor: "pointer", background: "none", border: "none", color: "var(--text-secondary)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)" }}>
<Ico n="Save" s={14} />Save current settings as template
</button>
</div>
)}
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 5 }}>
{template.sets.map((s) => (
<span key={s} style={{ padding: "3px 8px", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-secondary)", ...(/\d/.test(s) ? mono : {}) }}>{s}</span>
))}
</div>
</div>
<Advanced templateName={template.name} scrollerRef={railScrollerRef} />
</ScrollArea>
</aside>
</div>
</div>
);
}
window.CTVChannelBuilder = ChannelBuilder;
})();
@@ -0,0 +1,163 @@
# Handoff: ChicoryTV — Channel Builder (+ admin design system)
## Overview
The **Channel Builder** ("New Channel") is a ground-up replacement for ErsatzTV's four-editor
channel-creation chain (Channel → Schedule → Schedule Items → Playout). It is **one screen**: browse
your media library on the left, build the channel's lineup in the center, set friendly options +
a Channel Template on the right, and hit **Create Channel** — which (in the real app) builds all the
underlying entities atomically. It ships inside the **ChicoryTV admin**, a dark, dense, app-like
console and its React component library.
## About the design files
The files in this bundle are **design references created in HTML/React** — prototypes showing the
intended look and behavior, **not production code to ship directly**. The task is to **recreate these
designs in the target codebase's environment** using its established patterns. Per the redesign brief,
the intended target is a **React SPA over the ErsatzTV REST API** (replacing Blazor/MudBlazor); if that
app doesn't exist yet, scaffold it (Vite + React + TypeScript is a reasonable default) and implement
there. Nothing here depends on Blazor.
The prototypes are built on a real design system (React primitives + CSS-custom-property tokens +
three themes) that lives in the parent project — treat that as the component/token source of truth.
## Fidelity
**High-fidelity.** Final colors, typography, spacing, radii, motion, and interactions are all resolved.
Recreate pixel-for-pixel using the codebase's real component library (promoting these primitives into
it, or mapping to existing equivalents). All values below are exact.
---
## Screens / Views
### New Channel — Channel Builder (primary deliverable)
**Purpose:** create a working channel by adding library items to a lineup and setting a few friendly
options; technical config comes from a selectable Channel Template.
**Layout:** full-height column. A **builder toolbar** (fixed, 12px×20px padding, hairline bottom
border): accent icon tile + "New Channel" title + live summary (`N in lineup · total duration`) on the
left; **Cancel** (ghost) and **Create Channel** (primary, right) — Create is disabled until there's a
name + ≥1 lineup item, tooltip below. Below the toolbar, a 3-column grid, full height:
`grid-template-columns: minmax(0,1.2fr) minmax(0,1fr) 304px` (columns shrink, never overflow; the
304px rail is fixed). Each column scrolls independently.
**Column 1 — Library browser** (`overflow:hidden` section):
- Header: a **Segmented** source toggle **Library / Collections** (sliding highlight), a spacer, and a
grid/compact **density** Segmented. Then a search input (`Search shows & movies…` /
`Search collections…`). Then, in Library mode only, pill **filter chips** (All, Cartoons, Movies,
TV Shows, Music Videos, Documentaries) — active chip uses accent-soft bg + accent text.
- Body (scrolls): **poster grid** `repeat(auto-fill, minmax(104px,1fr))`, gap 10 — each poster is a
2:3 tinted, diagonally-striped placeholder (deterministic hue from title) with a bottom gradient
scrim, title (12px semibold) + mono meta, and a type icon top-left (Tv/Film/Music/Radio, or
Layers/Sparkles/FolderTree/Repeat for collection kinds). Compact mode is a 30×40 thumb + title +
`type · meta` row. **Double-click or drag** to add; added cards show an accent ring + a **pop-in**
check badge (top-right).
**Column 2 — Lineup** (the channel's content, `overflow:hidden` section, accepts drops):
- Header: "LINEUP" eyebrow, a **Sequential / Shuffled** status badge (icon, informational — the real
control is the Shuffle toggle in the rail), a spacer, a **Clear** ghost button.
- Body (scrolls): ordered rows, gap 7. Each row: grip handle, order number (mono) when Sequential or a
shuffle glyph when Shuffled, a 34×46 poster thumb, title (14px semibold) + `type · library`, mono
duration, and a remove (×) button. **Drag-to-reorder** (HTML5 DnD; a `0 -2px 0 accent` top-border
marks the drop target; dragged row drops to 0.35 opacity). Empty state = dashed dropzone. A trailing
dashed "double-click or drag more titles to add" hint.
**Column 3 — Settings rail** (fixed 304px, `background: surface-card`, scrolls via an overlay
scrollbar — hidden native bar so no reflow, subtle fading thumb):
- **Channel name** input; **Number** input with an `AUTO` mono chip trailing.
- **Channel image**: a dashed dropzone (drop or click → file input; live `FileReader` preview). Below,
two preview tiles — **Guide logo** (logo + name + "listing") and **On-screen bug** (logo bottom-right
over a striped "video frame"). A "Set logo & bug separately" disclosure reveals two image buttons.
Default: the one image drives both.
- **Shuffle** toggle (label + "Plays in a random order." / "Plays in sequence (chronological)."). Shows
an "overrides template" tag when it differs from the selected template's default.
- **Always playing** toggle — the live-TV concept. Rendered in the **live color** (`--ctv-live`) with a
tinted row + a "● LIVE" pill when on. Helper: "Like live TV — advances on schedule even when nobody is
watching." Also shows the "overrides template" tag when diverging. *(Backend gap #68 — still needs
confirmation.)*
- **Channel Template** picker: a button showing the current template (name + one-line desc + Custom
badge). Opens a list of built-ins + custom, each with a radio, name, desc; a "Save current settings as
template…" action. Selecting a template **applies its Shuffle + Always-playing defaults** and shows
its config as chips (e.g. `1080p H.264`, `HLS Segmenter`, `Sequential`, `Always on`, `Pre + post filler`).
- **Advanced** disclosure: a **SlidersHorizontal** header + `N fields · <template>`. Two buttons —
**Override settings** (one click → editable fields, active-orange in dual theme) and **View defaults**
(read-only chips). Clicking the active one collapses. The field region animates via `max-height`.
~24 fields grouped Streaming / Filler / Playback / Behavior.
### Other admin screens (context; already in the design system)
- **Dashboard** — on-air cards, stat tiles, condensed health panel, activity feed, release notes.
- **Channels** — dense table, bulk-select, `(D)`/`(H)` markers, streaming-mode badges, inline actions.
- **Guide / EPG** — dark channels×time grid, "now" marker, live focal color, jump-to-now.
- **Schedule editor** — two-pane, drag-to-reorder items + tabbed inspector.
- **Playouts** — now/next monitor (live preview, up-next, timeline strip, upcoming list).
- **Libraries** — Local/Plex/Jellyfin/Emby source cards with scan status.
- App shell: 232px left sidebar (nav + health), 52px top bar (search, Connect menu). Primary "big"
button is **top-right** on every screen.
---
## Interactions & behavior
- **Add to lineup:** double-click a library card OR drag it onto the lineup. Dedupes by id; added cards
show a check.
- **Reorder lineup:** native drag-and-drop; drop-target top-border indicator; source dims to 0.35.
- **Shuffle vs Sequential:** rail toggle; lineup order display switches (numbers vs shuffle glyph).
- **Channel image:** drop/select → `FileReader.readAsDataURL` → live logo + bug preview; falls back to a
generated initials chip (deterministic color from the name) when empty.
- **Template selection:** applies the template's shuffle + always-playing defaults; toggles then show
"overrides template" if the user changes them.
- **Advanced:** one-click Override (editable) / View defaults (read-only); `max-height` collapse
animation; on expand, the panel smooth-scrolls just enough to bring the Advanced header to the top.
- **Create Channel:** enabled only with a name + ≥1 item.
- **Themes:** `data-theme` on `<html>` = ``""`` (warm, default) / `cool` / `dual`. In **dual**, the focal
warm hue drives primary actions, active nav, and selection via `--action-primary`.
### Motion (iOS/macOS-subtle; respect `prefers-reduced-motion`)
- Durations 90 / 140 / 220340ms. Standard ease `cubic-bezier(0.2,0,0,1)`; entrance/settle
`cubic-bezier(0.16,1,0.3,1)`.
- Segmented control: highlight **slides** (transform, ~300ms). Cards: **hover-lift** (`translateY(-2px)`
+ shadow). Buttons/rows: **press-scale** (`scale(0.96)`, ~150ms). Selection check: **pop-in**
(`scale` + fade). Advanced: **max-height** accordion. Switches slide. Live status dot pings.
## State management
Per Channel Builder instance: `search`, `libraryFilter`, `source` (library|collections), `compact`,
`lineup[]` (ordered items), drag ids (`dragLib`, `dragRow`, `overRow`), `name`, `number`, `shuffle`,
`always`, `logoSrc` (dataURL|null), selected `template` id, `tplOpen`, and Advanced `mode`
(closed|view|override) + edited field values. Derived: `addedIds`, filtered pool, total duration,
`canCreate`. Real app: replace mock arrays with the **library browse/search API (#65)**, **Channel
Templates entity (#64)**, and the **composite create endpoint (#63)**.
## Design tokens (exact)
**Surfaces:** app `#0F1115` · sunken `#0B0D11` · card `#171A21` · raised `#1C2029` · selected `#222732`.
**Borders:** hairline `#262B36` · control `#333A48`.
**Text:** primary `#E6E9EF` · muted `#9AA4B2` · faint `#626B7A`.
**Accent:** warm chicory `#E08A3C` (default) · cool periwinkle `#5B7CFA` · soft = accent @14%.
**Semantic alias:** `--action-primary` = accent (warm/cool) or **`--ctv-live`** in dual.
**Status:** ok `#3FB984` · warn `#E0A83D` · error `#E5484D` · **live** `#E08A3C` (warm) / `#F0663F` (dual),
each with a ~1416% soft tint.
**Radii:** chips 3px · buttons/inputs 5px · cards/rows/menus 7px · dialogs 10px · pills full.
**Type:** Geist (UI) + Geist Mono (numerics, tabular figures). Base 13px; scale 11·12·13·14·16·20·26·34;
weights 400/500/600. Eyebrows 11px/600 uppercase, 0.06em tracking.
**Spacing:** 4px grid. Fixed chrome: sidebar 232 · top bar 52 · rows 44 · controls 34 (sm 28).
**Elevation:** border + subtle shadow (sm `0 1px 2px/.40`, md `0 4px 12px/.45`, pop `0 8px 28px/.55`,
dialog `0 16px 40px/.55`); live elements get a small accent glow.
## Assets
- **Icons:** [Lucide](https://lucide.dev) (2px stroke), via CDN in the prototypes; use your icon lib's
Lucide set. Glyphs used: Plus, Check, X, Search, Library, FolderTree, LayoutGrid, Rows3, GripVertical,
Shuffle, ListOrdered, Radio, Film, Tv, Music, Layers, Sparkles, Repeat, Image, SlidersHorizontal,
SquarePen, Eye, CircleCheck, Circle, Save, ChevronDown/Up/Right.
- **Brand mark:** `assets/chicory-mark.svg` (periwinkle chicory flower) — do not redraw; reference it.
- **Posters/artwork:** placeholders only (striped, tinted). Real artwork comes from the library API (#65).
- No other raster assets.
## Files
In the parent project (download it alongside this folder):
- `templates/chicorytv-admin/ChannelBuilder.jsx`**the Channel Builder** (also copied into this folder).
- `templates/chicorytv-admin/{Shell,Dashboard,Channels,Epg,ScheduleEditor,Playouts,ScheduleLibrary}.jsx`,
`data.js`, `icons.jsx`, `app.html`, `ds-base.js` — the admin shell + other screens.
- `components/` — the React component library (`.jsx` + `.d.ts` per component; namespace
`window.ChicoryTVDesignSystem_eb3b61`).
- `tokens/` + `styles.css` — design tokens and the three themes (`tokens/themes.css`).
- `readme.md` — full design-system documentation.
- `docs/superpowers/specs/2026-07-01-chicorytv-ui-redesign-design.md` — the redesign brief; **§6** is the
Channel Builder spec, **§8** records exactly what was built + backend gaps (#62/#63#68).
To view the whole admin: open `templates/chicorytv-admin/app.html` and click **New Channel**.
@@ -0,0 +1,229 @@
# ChicoryTV UI Redesign — Visual Direction Brief (Pass 1)
**Date:** 2026-07-01
**Issue:** [ersatztv#59 — full UI redesign and ChicoryTV rebrand](http://192.168.1.95:3000/timothy/ersatztv/issues/59)
**Status:** Design direction locked. **Pass 1 built (2026-07-02):** the ChicoryTV **design system** (React component library + tokens + three themes) and the full admin (Dashboard, Channels, Guide/EPG, Schedule editor, Playouts, Libraries) now exist as a reusable template, and the §6 Create Channel flow is built as the **Channel Builder** screen. See **§8** for exactly what shipped, the decisions made during build, and handoff notes.
> Scope of *this* document: the **visual-direction brief** for Pass 1 — the decisions plus the exact prompts to paste into Claude Design (claude.ai/design) to generate 4 hero screens. It is **not** the full #59 implementation spec. That comes after a look is locked.
---
## 1. Context & goals
ErsatzTV is a custom IPTV channel server (C#/.NET 10, currently a Blazor Server + MudBlazor UI, ~60 Razor pages). The current UI is utilitarian ("Sonarr-like") and was never designed. Issue #59 calls for a complete redesign built **around the REST API** rather than legacy page patterns, plus a rename to **ChicoryTV**, with drag-and-drop workflows, better creation defaults/templates, and discoverable-but-not-heavy advanced config.
**Goals (from #59):**
- Slick, modern, app-like UI built around the API.
- Drag-and-drop for channel lineup, scheduling, ordering, editing.
- Better defaults/templates for channel creation (subtitles, shuffle, schedule/playout defaults).
- Advanced config discoverable without making common workflows heavy.
- Rebrand user-facing surfaces to ChicoryTV.
**Success for Pass 1:** the user (not design-oriented) can look at 4 rendered hero screens and say "yes, that direction" — locking a look + brand cheaply before any spec/prototype/build work.
---
## 2. Strategic decisions (locked 2026-07-01)
| Decision | Choice | Why |
|---|---|---|
| **Frontend stack** | **React SPA on the REST API** (not Blazor/MudBlazor reskin) | All design is AI-driven; LLMs generate React far more reliably than Blazor. Claude Design outputs React, so its output is *shippable* and the `design-sync` pipeline becomes usable (on-brand AI designs → 1:1 shipped code). Satisfies #59's "around the API" goal natively. |
| **API sequencing** | **Design pulls the API** | If a design goal needs a new API feature, build the API feature first. #59 is *not* hard-blocked on API completeness. |
| **Look & feel** | **Komodo-slick** | Dynamic, app-like, no full-page reloads, dense/technical. Explicitly **not** Sonarr-style. |
| **Brand** | **ChicoryTV — dark + chicory blue** | See §3. |
**Accepted costs of React:** full UI rewrite, dual-language (C# backend + TS frontend), new build/deploy step, transition period running both UIs.
**Pipeline note:** `/design-sync` was gated on a React component library existing (MudBlazor isn't one). **That library now exists** — the ChicoryTV design system in this project ships React primitives (`Button`, `Card`, `Input`, `Select`, `Switch`, `Badge`, `StatusDot`, `ChannelLogo`, `NavItem`, `Tabs`, …) with tokens and three themes, compiled to `_ds_bundle.js` (+ `_ds_manifest.json`). The design-sync prerequisite — on-brand components that can round-trip design ↔ code — is therefore met **on the design side**; remaining work is pointing `/design-sync` at this design system and/or wiring these components into the real React SPA in the codebase (see §8.6).
---
## 3. Brand: ChicoryTV
"Chicory" is a real plant with a distinctive cornflower/periwinkle-blue flower (and a coffee/roasted association) — giving an ownable accent color and a name with a story. Direction: **dark, technical, focused**, one confident accent.
### Color tokens (draft — refine in Claude Design)
```
bg #0F1115 (near-black charcoal — app background)
surface #171A21 (cards / panels)
border #262B36 (hairline dividers)
text #E6E9EF (primary) muted #9AA4B2 (secondary)
accent #5B7CFA (chicory periwinkle-blue — primary actions, active nav)
ok #3FB984 warn #E0A83D err #E5484D
```
### Usage rules
- Accent used **sparingly** — primary buttons, active nav item, focus/selection, "live/on-air" emphasis. Not decorative.
- Dense rows with subtle hairline borders; generous but not wasteful spacing. TV/EPG grids read well on dark.
- **Typography:** clean sans (Inter-like) for UI; consider a tabular/mono for channel numbers, times, and durations so grids align.
- **Density:** app-like, information-dense (Komodo), not marketing-spacious. Prefer inline actions and hover affordances over full-page navigation.
- **Logo/wordmark:** "ChicoryTV" wordmark with a chicory-flower/periwinkle mark; let Claude Design propose 2-3 options.
---
## 4. The 4 hero screens (Pass 1)
Real domain vocabulary is included so renders show true ChicoryTV content, not placeholders. **Use the app's own terms:** Channels, Schedules / Schedule Items, Playouts (Classic/Block/Sequential/Scripted/External JSON), Blocks, Playout Templates, Deco / Deco Group, Filler (Pre/Mid/Post-Roll, Tail, Fallback), Collection Type, Playback Order, Playout Mode, Guide Mode, and the XMLTV "programme" for EPG.
### 4.1 Dashboard (reimagined as a real overview)
Today `Index.razor` is only health-checks + release notes. **Redesign it into a proper landing overview:**
- **On-air now** — per-channel "now playing" strip/cards: channel number + logo + name, current program title, progress bar to next, live/on-air accent state.
- **At-a-glance counts** — channels, active playouts, transcodes running, libraries.
- **Health panel** — the existing `HealthCheckResult` list (status: Pass/Info/Warning/Fail with green/blue/amber/red), condensed; click-through to detail.
- **Recent activity / errors** — recent scans, playout resets, transcode failures.
- **Release notes** — collapsible card, secondary.
### 4.2 Channels list
Entity: `Channel`. Dense table, the template for all list pages.
- **Columns:** Number (with `(D)` disabled / `(H)` hidden-from-EPG markers) · Logo · Name · Language (preferred audio) · Streaming Mode (`HLS Direct` / `HLS Segmenter` / `MPEG-TS` / `MPEG-TS (Legacy)`) · FFmpeg Profile.
- **Row actions (inline icons):** Preview · Stop transcode (only when active) · Edit · Troubleshoot playback · Delete.
- **Top bar:** "Add Channel", "Edit Channel Numbers", "Show Disabled" toggle, search/filter.
- **States:** disabled/hidden rows visually de-emphasized; active-transcode indicator.
- **Redesign opportunity:** bulk-select + bulk actions; group-by channel Group; drag-to-reorder channel numbers (replaces the separate "Edit Channel Numbers" page).
- **Borrowed-concept enrichments:**
- **Lineup health at a glance** (#72) — per-row status: user-made vs auto-generated origin, and a prominent **"failed / no content"** state (empty collection, no playout, broken source).
- **Resume badge** (#68) — sequential/resume channels show a "Continue watching — resumes at S1E4 12:30" indicator.
### 4.3 Schedule / playout editor (introduce drag-and-drop)
**Net-new capability:** today, schedule-item ordering is up/down arrow buttons. The redesign introduces **drag-to-reorder**.
- **Left:** ordered list of **Schedule Items** (drag handles) — each row shows Start Time · Collection · Playout Mode.
- **Right:** selected-item detail form. Real fields:
- **Start Type** (Dynamic/Fixed) + Start Time + Fixed Start Time Behavior (Inherit/Strict/Flexible)
- **Collection Type** — Collection, Television Show/Season, Artist, Multi Collection, Smart Collection, Search Query, Playlist, Rerun (First Run / Rerun) — each swaps its picker.
- **Playback Order** — Chronological, Random, Shuffle, Shuffle In Order, Marathon, Season/Episode, Multi-Episode Shuffle (+ Marathon sub-fields: Group By, Shuffle Groups/Items, Batch Size).
- **Playout Mode** — Flood / One / Multiple / Duration (+ Multiple Mode & Count, Playout Duration).
- **Fill With Group Mode, Tail Mode, Custom Title, Guide Mode (Normal/Filler)**.
- **Filler** — Pre-Roll, Mid-Roll, Post-Roll, Tail, Fallback presets.
- **Overrides** — Watermarks, Graphics, Preferred Audio/Subtitle Language, Subtitle Mode.
- **Discoverability:** show the common fields (Collection, Playback Order, Playout Mode) up front; tuck Filler/Overrides into progressive-disclosure sections.
- **Borrowed-concept enrichments:**
- **Weighted / fair-share distribution** (#70) — per-show weight inputs (sliders or %) when a schedule item mixes multiple shows ("Show A 70% / Show B 30%"), plus a fair-share toggle.
- **Persistent shuffle + Reroll** (#71) — a "Reroll" button and a visible shuffle-state indicator, so shuffle is deliberate and predictable rather than opaque.
- **Pad to clock boundary** (#77) — an option to snap programs to :00/:15/:30 with filler, for tidy broadcast-style guides.
- **Seasonal / date condition** (#73) — an optional date-range predicate on a schedule item ("active only between dates").
### 4.4 Guide / EPG grid
Today the guide is XMLTV output only (no rendered grid). Design a **dark time-grid** program guide.
- **Layout:** channels down the left (number + logo + name), time across the top, program blocks in the grid.
- **Program block** (`programme`): title, sub-title, startstop times, category chip, artwork/icon, description on hover/expand.
- **States:** "now" vertical time-marker; current/on-air program gets the accent; filler (Guide Mode = Filler) visually distinct from real programs.
- **Controls:** time-range scrubber, jump-to-now, channel filter.
---
## 5. How to use this in Claude Design
Open claude.ai/design and create a project (e.g. "ChicoryTV"). Paste the **master brief** first, then generate one screen at a time with the per-screen prompts. Iterate on the look on the Dashboard first (it sets the shell), then apply the locked style to the rest.
### Master brief (paste first)
> Design **ChicoryTV**, a dark, slick, app-like admin UI for a self-hosted IPTV/custom-TV channel server (think a modern take on a media-server admin, in the spirit of Komodo — dense, technical, no full-page reloads). Dark theme: background `#0F1115`, surfaces `#171A21`, hairline borders `#262B36`, primary text `#E6E9EF`, muted `#9AA4B2`. One accent — chicory periwinkle-blue `#5B7CFA` — used only for primary actions, active nav, focus, and live/on-air states. Status colors: ok `#3FB984`, warn `#E0A83D`, error `#E5484D`. Clean sans UI type; tabular/mono for numbers, times, durations. Left sidebar nav (Dashboard, Channels, Schedules, Playouts, Collections, Libraries, Settings), compact top bar. Information-dense, inline actions, hover affordances. Include a "ChicoryTV" wordmark with a small periwinkle chicory-flower mark.
### Per-screen prompts
1. **Dashboard:** "A landing overview: an 'On air now' section of per-channel cards (channel number, logo, name, current program title, progress bar to next, live accent), a row of stat tiles (Channels, Active Playouts, Transcodes, Libraries), a condensed Health panel (rows with Pass/Info/Warning/Fail status icons in green/blue/amber/red), a Recent activity list, and a collapsible Release notes card."
2. **Channels list:** "A dense channels table. Columns: Number, Logo, Name, Language, Streaming Mode (values like 'HLS Segmenter', 'MPEG-TS'), FFmpeg Profile. Inline row actions: preview, edit, delete. Top bar: 'Add Channel', 'Show Disabled' toggle, search. Disabled rows de-emphasized. Bulk-select checkboxes. Each row has a small status dot — green for healthy, red for 'no content / won't play', and a subtle 'auto' tag for auto-generated channels; a couple of rows show a 'Continue watching — resumes at S1E4' badge."
3. **Schedule editor:** "Two-pane schedule editor. Left: a drag-to-reorder list of schedule items (drag handles), each showing start time, collection name, and playout mode. Right: a detail form for the selected item with Collection Type, Playback Order, and Playout Mode as the primary fields, and collapsible 'Filler' and 'Overrides' sections. Show a 'Reroll' button next to a shuffle indicator; when the item mixes multiple shows, show per-show weight sliders; include a 'Pad to :00/:30' toggle and an optional date-range 'active between' condition. Dark, dense."
4. **EPG grid:** "A dark TV program guide grid: channels down the left (number, logo, name), time across the top, program blocks in the grid with title/sub-title and a category chip. A vertical 'now' time marker; the current program on each channel uses the accent color; filler programs look muted. A jump-to-now button and time scrubber."
5. **Auto-tuning wizard** *(borrowed from PseudoTV, #69)*: "A 'Generate channels' screen. Step 1: choose what to generate channels from — a set of toggle cards labelled 'By TV Network', 'By TV Genre', 'By Movie Genre', 'By Studio', 'One per Show (24/7)', 'By Smart Collection'. Step 2: a preview grid of the channels that would be created — each a card with an auto-picked logo, a channel name (e.g. 'Comedy', 'Warner Bros.', 'Breaking Bad 24/7'), an item count, and an include checkbox. A 'Template' dropdown applies default settings to all. A prominent 'Create 12 channels' button. Dense, app-like."
Refine tokens/typography live, then regenerate the other screens once the Dashboard look is locked.
> **Enrichments cross-reference:** the annotations above map to filed issues — Channels-list status/resume (#72, #68); Schedule-editor weights/reroll/pad/seasonal (#70, #71, #77, #73); Auto-tuning (#69). These enrich the hero screens; they're not separate deliverables except the Auto-tuning wizard (screen 5).
---
## 6. Create Channel — workflow redesign (pulled forward)
The inherited channel-creation flow is the biggest UX pain and is being fully rethought. Today, making a working channel is a chain of four disconnected editors across four entities — **Channel → Schedule → Schedule Items → Playout** — and the channel form alone dumps ~30 advanced fields with no sane defaults. There is no way to browse your library while building, and no templates/presets.
### Target experience: one library-to-lineup builder
**One screen** replaces the four-editor chain. Behind the scenes it still creates Channel + Collection + Schedule + Schedule Items + Playout — the user never sees those concepts.
- **Left — Library browser:** artwork grid of shows/movies, searchable, filterable by library. (Wholly missing today.)
- **Right — Channel lineup:** items added from the library, drag-to-reorder. This *is* the channel's content.
- **Add by** double-click **or** drag.
**Friendly options, always visible (the 90% case):**
- **Name** + auto-suggested channel **Number**.
- **Shuffle** toggle (on = Shuffle playback order; off = Sequential/Chronological).
- **"Always playing (like live TV)"** toggle — plays continuously and advances on schedule even when unwatched. *(Needs backend confirmation — see gap 6.)*
- **Channel image:** drop one image, used by default as **both** the listing **logo** and the on-screen **bug** (watermark), with live preview of both; expandable to set them separately (as possible today).
**Technical settings = a Channel Template (with override):**
- A **Channel Template** picker bundles the clunky technical/behavioral config — FFmpeg/transcode profile, fillers (pre/mid/post-roll), interleaving, subtitle behavior, playout defaults. A sensible default template is pre-selected, so most channels need **zero** technical config.
- **Both built-in and custom templates:** ship a few sensible built-ins (e.g. "Standard", "Music videos"); users can save their own. Where a template can supply a default, it does — templates are the assumed path.
- **"Advanced"** disclosure reveals the full ~30-field set for power-user overrides.
**One "Create" button** builds all underlying entities atomically.
### Backend gaps this requires (design pulls the API)
Tracked under epic **#62**; sub-issues #63#68:
1. **Composite "create channel from lineup" endpoint** (#63) — creates channel + collection + schedule + schedule items + playout atomically (today they are separate operations).
2. **Channel Templates entity** (#64) — bundles transcode profile, fillers, interleave, subtitle/shuffle/playout defaults; built-in + user-saved. (#59 explicitly asks for templates.)
3. **Library browse + search API with artwork** (#65) — powers the left pane.
4. **Full image-type support for logo/watermark** (#66) — WebP appears unsupported today. Support can mean *either* adding decode support *or* **transparently re-encoding uploaded images to a supported format on upload** if FFmpeg/SkiaSharp is the blocker — the user should never hit an "unsupported image" wall.
5. **Unified logo/bug image handling** (#67) — one uploaded image drives both, separable.
6. **"Always-on vs on-demand" playout** (#68) — needs investigation; ErsatzTV playouts are continuous by design, so an on-demand mode may not exist yet.
### Claude Design prompt — Create Channel
> A "Create Channel" screen for a dark IPTV admin (ChicoryTV palette). Two main panes: LEFT is a searchable library browser — a grid of show/movie poster artwork with a search bar and a library filter dropdown. RIGHT is the "channel lineup" — a vertical list of the shows added from the library, each with a drag handle to reorder and a remove button. Above the panes: a channel Name field, an auto-filled channel Number, a Shuffle toggle, an "Always playing (like live TV)" toggle, and a single image drop-zone labelled "Channel image" showing a live preview used as both the listing logo and the on-screen bug, with an "advanced: set separately" link. A "Template" dropdown (default: "Standard") sets technical settings, with an "Advanced settings" collapsible section below it. A prominent "Create channel" button. Dense, app-like, chicory periwinkle-blue accent on the primary button and active elements.
---
## 7. Out of scope & next steps
**Out of scope now:** the full #59 implementation spec, React app architecture (routing/state/auth/build/deploy), the media-libraries/collections browser (Pass 2), browser-based channel playback (tracked separately in #60), the actual rebuild. (Channel creation was originally deferred to Pass 2 but is now specified in §6.)
**Next steps (after a look is locked):**
1. Design the §6 Create Channel screen in Claude Design; file the §6 backend-gap issues.
2. Pass 2 screens: libraries/collections browser.
3. Prototype the hard workflows (drag-drop lineup/scheduling, create-channel builder) as clickable React.
4. Full #59 spec: IA, React app architecture, API gaps to backfill, migration/cutover (dual-stack), rebrand checklist.
5. Build.
**Related:** #2 (REST API — the foundation this UI sits on), #58 (MCP server), #60 (browser channel playback, post-redesign).
---
## 8. What was built (2026-07-02) — Pass 1 → design system + Channel Builder
Pass 1 rendered as a working, reusable **design-system project**, not just static hero mocks.
### 8.1 Design system (the React component library #59 wanted)
- **Tokens** (`tokens/`): layered surfaces, three-weight text, one accent + status colors, 4px spacing grid, radii, elevation, motion, typography (Geist / Geist Mono). Consumed as CSS custom properties (`--ctv-*` plus semantic aliases like `--action-primary`, `--status-live`).
- **Three themes** (`tokens/themes.css`, via `data-theme` on `<html>`): **warm chicory** (roasted-amber accent, default `:root`), **cool periwinkle** (`#5B7CFA`), **dual accent** (periwinkle app + warm `#F0663F` focal for live/attention). One attribute switches everything because components read the aliases.
- **Components** (`components/`, namespace `window.ChicoryTVDesignSystem_eb3b61`): Button, IconButton, Input, Select, Switch, Checkbox, Badge, StatusDot, Tag, ChannelLogo, Card, Stat, ProgressBar, Spinner, Toast, Tooltip, NavItem/NavSection, Tabs — each with a `.d.ts` and a specimen card. Compiled to `_ds_bundle.js`.
- Icons: **Lucide** (deliberate substitution for MudBlazor's Material Symbols).
### 8.2 Admin template (`templates/chicorytv-admin/`)
The full interactive admin — sidebar + top bar + all screens — as a single reusable template loaded via `ds-base.js` (which pulls the compiled bundle + stylesheets). Screens: Dashboard, Channels, **New Channel (Channel Builder)**, Guide/EPG, Schedule editor, Playouts, Libraries, plus a live 3-theme switcher. Screens are mock-data prototypes (`data.js`).
### 8.3 Channel Builder (implements §6)
One screen replacing the Channel→Schedule→Items→Playout chain:
- **Left — library browser:** poster-artwork grid, search, per-library filter chips, a **Library / Collections** source toggle (Smart/Manual/Multi/Rerun collections are addable as a single lineup entry), and a grid/compact density switch.
- **Center — the lineup:** add by **double-click or drag**, **drag-to-reorder**, remove, running count + total duration, empty-state dropzone.
- **Right — friendly options:** Name + auto-suggested Number; a **single channel-image dropzone** with live **guide-logo** and **on-screen-bug** previews (expandable to set separately); **Shuffle** and **Always playing (like live TV)** toggles; a **Channel Template** picker (built-in + custom, “save as template”); and an **Advanced** disclosure over the ~30-field override set (one-click **Override** to edit / **View defaults** read-only).
- One atomic **Create Channel** button.
### 8.4 Decisions & refinements made during build (beyond the §6 brief)
- **Templates define playback, not just transcode.** A Channel Template now also carries **Shuffle** and **Always-playing** defaults; selecting a template applies them, and each toggle shows an “overrides template” tag when the user diverges. (Extends §6 and the #64 template entity.)
- **Dual-accent theme** routes **primary actions, active nav, and selection** through the focal warm hue (`--action-primary`) — Create button, selected library posters, active Advanced tab — so the two accents read distinctly while warm/cool stay coherent.
- **“Always playing” renders in the live/on-air color** (`--ctv-live`) with a LIVE pill — it *is* the live-TV concept and is the element that most visibly distinguishes the dual theme.
- **Motion system** (iOS/macOS feel): sliding-highlight segmented controls, hover-lift cards, press-scale controls, pop-in selection checks, animated max-height disclosure, gentle `cubic-bezier(0.16,1,0.3,1)` easing, `prefers-reduced-motion` fallback.
- **Primary (“big”) button standardized to top-right** on every screen (was mixed).
- **Overlay scrollbars** on tall panels (hidden native bar → no reflow when it appears; subtle fading thumb).
### 8.5 Backend gaps — status unchanged
The §6 gaps (#62 / #63#68) still stand. **#68 “always-on vs on-demand”** is shown in the UI as a normal enabled toggle but **still needs backend confirmation.** Templates-carry-shuffle/always (8.4) maps onto the **Channel Templates entity #64**.
### 8.6 Handoff notes (for `/design-sync` and the React rebuild)
- Components are **design-grade React** (inline-style / CSS-var primitives), not yet wired to a production build (no bundler / routing / tests). For `/design-sync` to round-trip 1:1, either point it at this design-system project as the source of truth, or promote these into the codebase's real component library (same names/props).
- Admin screens are **mock-data prototypes** to be re-implemented against the REST API.
- Nothing depends on Blazor/MudBlazor — this is the React foundation the redesign assumed.
### 8.7 Still open (recommended next, larger)
- **Schedules → Channel Templates manager**: repurpose the standalone Schedules editor as the place to view/create the templates the builder consumes; relabel **Playouts** as a read-only now/next monitor; fold standalone **Collections** into the builder's source toggle. (Deferred as a fresh, larger task.)
@@ -0,0 +1,30 @@
<!-- @dsCard group="Brand" viewport="700x180" name="ChicoryTV wordmark" subtitle="Chicory-flower mark + wordmark; accent TV" -->
<!doctype html><html><head><meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400..700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="../styles.css">
<style>
body{margin:0;background:var(--surface-app);font-family:var(--font-sans);color:var(--text-primary)}
.wrap{padding:26px 28px;display:flex;align-items:center;gap:40px}
.lockup{display:flex;align-items:center;gap:13px}
.mark{width:40px;height:40px}
.wm{font:var(--weight-semibold) 27px/1 var(--font-sans);letter-spacing:-0.5px}
.tv{color:var(--ctv-accent)}
.chip{width:56px;height:56px;border-radius:var(--radius-md);background:var(--ctv-surface);border:1px solid var(--border-hairline);display:flex;align-items:center;justify-content:center}
.lbl{font:var(--weight-medium) var(--text-2xs)/1 var(--font-sans);letter-spacing:.05em;text-transform:uppercase;color:var(--text-disabled);margin-top:12px}
</style></head>
<body>
<div class="wrap">
<div>
<div class="lockup"><img class="mark" src="../assets/chicory-mark.svg" alt=""><span class="wm">Chicory<span class="tv">TV</span></span></div>
<div class="lbl">Primary lockup</div>
</div>
<div>
<div style="display:flex;gap:14px;align-items:center">
<img src="../assets/chicory-mark.svg" width="44" height="44" alt="">
<div class="chip"><img src="../assets/chicorytv-icon.svg" width="40" height="40" alt=""></div>
</div>
<div class="lbl">Mark · app icon</div>
</div>
</div>
</body></html>
@@ -0,0 +1,20 @@
<!-- @dsCard group="Colors" viewport="700x140" name="Accent — chicory periwinkle" subtitle="The single accent: primary actions, active nav, focus, live" -->
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../styles.css">
<style>
body{margin:0;background:var(--surface-app);font-family:var(--font-sans);color:var(--text-primary)}
.wrap{display:flex;gap:0;padding:20px 22px}
.sw{flex:1;height:88px;border-radius:0;display:flex;flex-direction:column;justify-content:flex-end;padding:9px 11px}
.sw:first-child{border-radius:var(--radius-md) 0 0 var(--radius-md)}
.sw:last-child{border-radius:0 var(--radius-md) var(--radius-md) 0}
.n{font:var(--weight-semibold) var(--text-xs)/1.3 var(--font-sans)}
.h{font:var(--text-2xs)/1.3 var(--font-mono);opacity:.8;margin-top:2px}
</style></head>
<body>
<div class="wrap">
<div class="sw" style="background:var(--ctv-accent-press)"><span class="n" style="color:#fff">Press</span><span class="h" style="color:#fff">#4C6BE8</span></div>
<div class="sw" style="background:var(--ctv-accent)"><span class="n" style="color:#fff">Accent</span><span class="h" style="color:#fff">#5B7CFA</span></div>
<div class="sw" style="background:var(--ctv-accent-hover)"><span class="n" style="color:#fff">Hover</span><span class="h" style="color:#fff">#6E8BFB</span></div>
<div class="sw" style="background:var(--ctv-accent-soft);border:1px solid rgba(91,124,250,.35)"><span class="n" style="color:var(--ctv-accent)">Soft</span><span class="h" style="color:var(--ctv-accent)">14%</span></div>
</div>
</body></html>
@@ -0,0 +1,21 @@
<!-- @dsCard group="Colors" viewport="700x150" name="Status colors" subtitle="ok / warn / error — solid and soft tints" -->
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../styles.css">
<style>
body{margin:0;background:var(--surface-app);font-family:var(--font-sans);color:var(--text-primary)}
.wrap{display:flex;gap:14px;padding:20px 22px}
.col{flex:1}
.bar{height:52px;border-radius:var(--radius-sm)}
.soft{height:30px;border-radius:var(--radius-sm);margin-top:8px;display:flex;align-items:center;padding:0 10px}
.n{font:var(--weight-semibold) var(--text-sm)/1 var(--font-sans);margin-top:10px}
.h{font:var(--text-2xs)/1 var(--font-mono);color:var(--text-disabled);margin-top:4px}
.st{font:var(--weight-medium) var(--text-xs)/1 var(--font-sans)}
</style></head>
<body>
<div class="wrap">
<div class="col"><div class="bar" style="background:var(--status-ok)"></div><div class="soft" style="background:var(--ctv-ok-soft)"><span class="st" style="color:var(--status-ok)">Healthy</span></div><div class="n">OK</div><div class="h">#3FB984</div></div>
<div class="col"><div class="bar" style="background:var(--status-warn)"></div><div class="soft" style="background:var(--ctv-warn-soft)"><span class="st" style="color:var(--status-warn)">Warning</span></div><div class="n">Warn</div><div class="h">#E0A83D</div></div>
<div class="col"><div class="bar" style="background:var(--status-error)"></div><div class="soft" style="background:var(--ctv-error-soft)"><span class="st" style="color:var(--status-error)">Error</span></div><div class="n">Error</div><div class="h">#E5484D</div></div>
<div class="col"><div class="bar" style="background:var(--status-live)"></div><div class="soft" style="background:var(--ctv-live-soft)"><span class="st" style="color:var(--status-live)">On air</span></div><div class="n">Live</div><div class="h">#5B7CFA</div></div>
</div>
</body></html>
@@ -0,0 +1,20 @@
<!-- @dsCard group="Colors" viewport="700x150" name="Surfaces & borders" subtitle="Layered dark surfaces from app background up to selected" -->
<!doctype html><html><head><meta charset="utf-8">
<link rel="stylesheet" href="../styles.css">
<style>
body{margin:0;background:var(--surface-app);font-family:var(--font-sans);color:var(--text-primary)}
.wrap{display:flex;gap:12px;padding:20px 22px;flex-wrap:wrap}
.sw{width:150px;height:96px;border-radius:var(--radius-md);border:1px solid var(--border-hairline);display:flex;flex-direction:column;justify-content:flex-end;padding:10px 12px}
.n{font:var(--weight-medium) var(--text-xs)/1.3 var(--font-sans);color:var(--text-primary)}
.h{font:var(--text-2xs)/1.3 var(--font-mono);color:var(--text-disabled);margin-top:2px}
</style></head>
<body>
<div class="wrap">
<div class="sw" style="background:var(--ctv-bg-sunken)"><span class="n">Sunken</span><span class="h">#0B0D11</span></div>
<div class="sw" style="background:var(--ctv-bg)"><span class="n">App bg</span><span class="h">#0F1115</span></div>
<div class="sw" style="background:var(--ctv-surface)"><span class="n">Surface</span><span class="h">#171A21</span></div>
<div class="sw" style="background:var(--ctv-surface-2)"><span class="n">Raised</span><span class="h">#1C2029</span></div>
<div class="sw" style="background:var(--ctv-surface-3)"><span class="n">Selected</span><span class="h">#222732</span></div>
<div class="sw" style="background:var(--ctv-surface);border:1px solid var(--ctv-border-strong)"><span class="n">Border strong</span><span class="h">#333A48</span></div>
</div>
</body></html>

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