feat(frontend): scaffold ChicoryTV SPA

refs #78
This commit is contained in:
2026-07-02 07:37:00 +02:00
parent c0b20a0ac5
commit b183c6a154
125 changed files with 17233 additions and 1 deletions
+27
View File
@@ -50,6 +50,33 @@ 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: 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
+3
View File
@@ -51,3 +51,6 @@ scripts/download-test-content.sh
docker-compose.override.yml
ErsatzTV/wwwroot/v2/
ErsatzTV/wwwroot/app/
web/dist/
web/node_modules/
+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");
}
}
+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)
+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 {
/** 0–100. 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` (0–100 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 16–18px 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 {
/** 16–18px 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 / 220–340ms. 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 ~14–16% 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, start–stop 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>
@@ -0,0 +1,17 @@
<!-- @dsCard group="Colors" viewport="700x140" name="Text on dark" subtitle="Primary / muted / faint — the three text weights" -->
<!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)}
.wrap{padding:22px 24px;display:flex;flex-direction:column;gap:14px}
.row{display:flex;align-items:baseline;gap:16px}
.lg{font:var(--weight-semibold) var(--text-xl)/1 var(--font-sans)}
.meta{font:var(--text-2xs)/1 var(--font-mono);color:var(--text-disabled);margin-left:auto}
</style></head>
<body>
<div class="wrap">
<div class="row"><span class="lg" style="color:var(--text-primary)">Primary — channel names, values</span><span class="meta">#E6E9EF</span></div>
<div class="row"><span class="lg" style="color:var(--text-secondary)">Muted — labels, secondary meta</span><span class="meta">#9AA4B2</span></div>
<div class="row"><span class="lg" style="color:var(--text-disabled)">Faint — disabled, placeholder</span><span class="meta">#626B7A</span></div>
</div>
</body></html>
@@ -0,0 +1,35 @@
<!-- @dsCard group="Colors" viewport="700x180" name="Color schemes" subtitle="Three shipped themes — set data-theme on <html>" -->
<!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..600&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:20px 22px;display:flex;flex-direction:column;gap:12px}
.row{display:flex;align-items:center;gap:12px}
.scope{display:flex;align-items:center;gap:0;border-radius:var(--radius-sm);overflow:hidden;border:1px solid var(--ctv-border)}
.sw{width:46px;height:34px}
.name{width:150px}
.t{font:var(--weight-medium) var(--text-sm)/1.1 var(--font-sans);color:var(--ctv-text)}
.d{font:var(--text-2xs)/1.2 var(--font-sans);color:var(--ctv-text-muted);margin-top:2px}
.code{font:var(--text-2xs)/1 var(--font-mono);color:var(--ctv-text-faint);margin-left:auto}
</style></head>
<body>
<div class="wrap">
<div class="row" data-theme="">
<div class="name"><div class="t">Warm chicory</div><div class="d">Primary · amber + leaf-green</div></div>
<div class="scope"><span class="sw" style="background:var(--ctv-bg)"></span><span class="sw" style="background:var(--ctv-surface)"></span><span class="sw" style="background:var(--ctv-accent)"></span><span class="sw" style="background:var(--ctv-secondary)"></span></div>
<span class="code">:root</span>
</div>
<div class="row" data-theme="cool">
<div class="name"><div class="t">Cool periwinkle</div><div class="d">Alternate · cool, focused</div></div>
<div class="scope"><span class="sw" style="background:var(--ctv-bg)"></span><span class="sw" style="background:var(--ctv-surface)"></span><span class="sw" style="background:var(--ctv-accent)"></span><span class="sw" style="background:var(--ctv-live)"></span></div>
<span class="code">[data-theme="cool"]</span>
</div>
<div class="row" data-theme="dual">
<div class="name"><div class="t">Dual accent</div><div class="d">Blue app · warm live/on-air</div></div>
<div class="scope"><span class="sw" style="background:var(--ctv-bg)"></span><span class="sw" style="background:var(--ctv-surface)"></span><span class="sw" style="background:var(--ctv-accent)"></span><span class="sw" style="background:var(--ctv-live)"></span></div>
<span class="code">[data-theme="dual"]</span>
</div>
</div>
</body></html>
@@ -0,0 +1,29 @@
<!-- @dsCard group="Spacing" viewport="700x150" name="Radii & elevation" subtitle="Tight corners; border-led elevation on dark" -->
<!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{padding:22px 24px;display:flex;gap:30px}
.grp{display:flex;gap:14px;align-items:flex-end}
.box{width:60px;height:60px;background:var(--ctv-surface-2);border:1px solid var(--border-hairline);display:flex;align-items:flex-end;justify-content:center;padding-bottom:6px}
.v{font:var(--weight-medium) var(--text-2xs)/1 var(--font-mono);color:var(--text-disabled)}
.sep{width:1px;background:var(--border-hairline);align-self:stretch}
</style></head>
<body>
<div class="wrap">
<div class="grp">
<div class="box" style="border-radius:var(--radius-xs)"><span class="v">3</span></div>
<div class="box" style="border-radius:var(--radius-sm)"><span class="v">5</span></div>
<div class="box" style="border-radius:var(--radius-md)"><span class="v">7</span></div>
<div class="box" style="border-radius:var(--radius-lg)"><span class="v">10</span></div>
<div class="box" style="border-radius:var(--radius-pill);width:80px"><span class="v">pill</span></div>
</div>
<div class="sep"></div>
<div class="grp">
<div class="box" style="border-radius:var(--radius-md);box-shadow:var(--shadow-sm)"><span class="v">sm</span></div>
<div class="box" style="border-radius:var(--radius-md);box-shadow:var(--shadow-md)"><span class="v">md</span></div>
<div class="box" style="border-radius:var(--radius-md);box-shadow:var(--shadow-pop)"><span class="v">pop</span></div>
<div class="box" style="border-radius:var(--radius-md);box-shadow:var(--shadow-lg)"><span class="v">lg</span></div>
</div>
</div>
</body></html>
@@ -0,0 +1,25 @@
<!-- @dsCard group="Spacing" viewport="700x150" name="4px spacing scale" subtitle="From 2px to 56px on a 4px base grid" -->
<!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{padding:24px;display:flex;align-items:flex-end;gap:14px}
.col{display:flex;flex-direction:column;align-items:center;gap:8px}
.bar{background:var(--ctv-accent);border-radius:2px;width:20px}
.v{font:var(--weight-medium) var(--text-2xs)/1 var(--font-mono);color:var(--text-secondary)}
</style></head>
<body>
<div class="wrap">
<div class="col"><div class="bar" style="height:4px"></div><span class="v">2</span></div>
<div class="col"><div class="bar" style="height:8px"></div><span class="v">4</span></div>
<div class="col"><div class="bar" style="height:12px"></div><span class="v">6</span></div>
<div class="col"><div class="bar" style="height:16px"></div><span class="v">8</span></div>
<div class="col"><div class="bar" style="height:24px"></div><span class="v">12</span></div>
<div class="col"><div class="bar" style="height:32px"></div><span class="v">16</span></div>
<div class="col"><div class="bar" style="height:40px"></div><span class="v">20</span></div>
<div class="col"><div class="bar" style="height:48px"></div><span class="v">24</span></div>
<div class="col"><div class="bar" style="height:64px"></div><span class="v">32</span></div>
<div class="col"><div class="bar" style="height:80px"></div><span class="v">40</span></div>
<div class="col"><div class="bar" style="height:104px"></div><span class="v">56</span></div>
</div>
</body></html>
@@ -0,0 +1,29 @@
<!-- @dsCard group="Type" viewport="700x200" name="Geist Mono — numbers, times, durations" subtitle="Tabular figures for everything numeric" -->
<!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..600&family=Geist+Mono:wght@400..600&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:22px 24px;display:flex;flex-direction:column;gap:16px}
.mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums}
.big{font:var(--weight-semibold) var(--text-2xl)/1 var(--font-mono);letter-spacing:-0.01em;font-variant-numeric:tabular-nums}
.lbl{font:var(--weight-medium) var(--text-2xs)/1 var(--font-sans);letter-spacing:.05em;text-transform:uppercase;color:var(--text-disabled);margin-bottom:6px}
table{border-collapse:collapse;font-size:var(--text-sm)}
td{padding:3px 18px 3px 0;color:var(--text-secondary)}
td.v{color:var(--text-primary)}
</style></head>
<body>
<div class="wrap">
<div style="display:flex;gap:44px;align-items:flex-end">
<div><div class="lbl">Channel</div><div class="big" style="color:var(--ctv-accent)">128.4</div></div>
<div><div class="lbl">Duration</div><div class="big">01:24:36</div></div>
<div><div class="lbl">Start</div><div class="big">20:00:00</div></div>
<div><div class="lbl">Bitrate</div><div class="big">8.2<span style="font-size:14px;color:var(--text-disabled)"> Mbps</span></div></div>
</div>
<table>
<tr><td>Aligned figures</td><td class="v mono">1,204</td><td class="v mono">00:03:12</td><td class="v mono">96.0%</td></tr>
<tr><td>Zero-locked</td><td class="v mono">0,007</td><td class="v mono">14:59:08</td><td class="v mono">-3.5</td></tr>
</table>
</div>
</body></html>
@@ -0,0 +1,21 @@
<!-- @dsCard group="Type" viewport="700x210" name="Geist — UI sans scale" subtitle="Page titles, card titles, body, labels" -->
<!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@300..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:22px 24px;display:flex;flex-direction:column;gap:15px}
.row{display:flex;align-items:baseline;gap:16px}
.meta{font:var(--text-2xs)/1 var(--font-mono);color:var(--text-disabled);margin-left:auto;white-space:nowrap}
</style></head>
<body>
<div class="wrap">
<div class="row"><span style="font:var(--weight-semibold) var(--text-xl)/1 var(--font-sans);letter-spacing:-0.01em">Channels &amp; Playouts</span><span class="meta">20 · 600 · Page title</span></div>
<div class="row"><span style="font:var(--weight-semibold) var(--text-lg)/1 var(--font-sans)">Recent activity</span><span class="meta">16 · 600 · Card title</span></div>
<div class="row"><span style="font:var(--weight-normal) var(--text-md)/1 var(--font-sans)">Emphasized body &amp; input text</span><span class="meta">14 · 400 · Body md</span></div>
<div class="row"><span style="font:var(--weight-normal) var(--text-sm)/1 var(--font-sans)">Table cells and default body — the workhorse size</span><span class="meta">13 · 400 · Body sm</span></div>
<div class="row"><span style="font:var(--weight-medium) var(--text-xs)/1 var(--font-sans);color:var(--text-secondary)">Field labels, chips, secondary meta</span><span class="meta">12 · 500 · Label</span></div>
<div class="row"><span style="font:var(--weight-semibold) var(--text-2xs)/1 var(--font-sans);letter-spacing:.06em;text-transform:uppercase;color:var(--text-disabled)">Section eyebrow</span><span class="meta">11 · 600 · Caps</span></div>
</div>
</body></html>
+190
View File
@@ -0,0 +1,190 @@
# ChicoryTV Design System
A dark, slick, app-like admin design system for **ChicoryTV** — a self-hosted IPTV /
custom-TV channel server. ChicoryTV is a modern reimagining of the **ErsatzTV** admin
UI (a .NET / Blazor Server + MudBlazor application): same product, same information
architecture, redrawn as a dense, technical, no-full-reload media-server console in the
spirit of tools like Komodo.
The product lets you turn a media library into personalized live TV channels — with an
EPG, channel scheduling, and streaming (IPTV/HLS/MPEG-TS) to any device.
## Sources
- **Codebase (ground truth):** `ersatztv/` — ErsatzTV fork, C#/.NET 10, Blazor Server, MudBlazor.
Key screens read for this system:
- `ErsatzTV/Shared/MainLayout.razor` — app bar, drawer nav, theme palette.
- `ErsatzTV/Pages/Channels.razor` — channel table + inline actions (the core view).
- `ErsatzTV/Pages/Playouts.razor` — playout list + playout-detail timeline items.
- `ErsatzTV/Pages/Schedules.razor`, `Libraries.razor`, `Index.razor` (health dashboard).
- **Upstream project:** ErsatzTV — https://ersatztv.org , https://github.com/ErsatzTV/ErsatzTV
- ChicoryTV is a **rebrand + redesign**, not a fork of the visual layer. The MudBlazor
green/Roboto theme is intentionally replaced; product vocabulary and IA are preserved.
> **Font substitution — please confirm.** ErsatzTV ships no brand font (MudBlazor defaults to
> Roboto). ChicoryTV adopts **Geist** (UI) + **Geist Mono** (numerics), loaded from Google
> Fonts. If you have a preferred/licensed brand typeface, share the files and it will be swapped in.
---
## Content fundamentals — how ChicoryTV writes
Voice is that of a **precise, technical operator tool** — terse, functional, confident. It
labels and instructs; it does not market or chit-chat.
- **Casing:** Title Case for primary actions and nav ("Add Channel", "Reset All Playouts",
"Edit Channel Numbers"); sentence case for helper text and descriptions.
- **Person:** mostly **impersonal / imperative** — verbs, not "you". Buttons are bare verbs
("Add Schedule", "Scan", "Delete", "Troubleshoot"). Occasional second person in help text.
- **Nouns are the product's domain language — use them exactly:** Channel, Schedule, Block,
Template, Deco, Playout (Classic / Block / Sequential / Scripted / JSON), Collection (Manual /
Smart / Multi / Rerun), Playlist, Library, Media Source (Local / Plex / Jellyfin / Emby),
FFmpeg Profile, Watermark, Filler, Streaming Mode (HLS Segmenter / HLS Direct / MPEG-TS),
EPG / XMLTV / M3U.
- **Numbers & time are first-class** and always concrete: channel numbers (`1.1`, `4.2`),
durations (`00:24:00`), clock times (`20:00:00`), counts (`1,204 items`). Rendered in mono.
- **Status is plain and specific:** "On air", "Healthy", "Not normalizing", "Playout build
failed — no items match the schedule window." State the thing and the consequence.
- **No emoji. No exclamation marks.** Tone stays flat and reliable. Icons carry affect, not punctuation.
- **Empty/blank:** say what is missing and the next action ("No playouts yet — Add Playout").
Examples pulled from the product surface: *"Show disabled"*, *"Show filler"*, *"Reset All
Playouts"*, *"Edit Channel Numbers"*, *"HLS Segmenter"*, *"Schedule Playout Reset"*,
*"This FFmpeg profile is not normalizing audio."*
---
## Visual foundations
**Overall vibe.** Dark, dense, engineered. Flat surfaces separated by hairline borders, one
cool accent, monospaced numerics. No decoration for its own sake — every pixel is data,
control, or the whitespace that organizes them.
**Color.** Near-black layered surfaces: app background `#0F1115`, sunken wells `#0B0D11`,
cards/rows `#171A21`, raised menus/hover `#1C2029`, selected `#222732`. Hairline borders
`#262B36` (dividers) and `#333A48` (controls). Text in three weights only: primary `#E6E9EF`,
muted `#9AA4B2`, faint `#626B7A`. **Exactly one accent** — in the primary *warm chicory* scheme this is roasted-amber `#E08A3C` (the cool *periwinkle* alternate uses `#5B7CFA`)
— reserved for primary actions, active nav, focus rings, and live/on-air state. Status colors
are used sparingly and only semantically: ok `#3FB984`, warn `#E0A83D`, error `#E5484D`. Each
status also has a ~14% soft tint for badges/backgrounds. Never introduce a second decorative hue.
**Type.** Geist for UI, base size **13px** (dense admin). Scale: 11 · 12 · 13 · 14 · 16 · 20 ·
26 · 34. Weights 400/500/600 (700 rare). Section eyebrows are 11px 600 uppercase, `0.06em`
tracking, faint. **Geist Mono with tabular figures** for everything numeric — channel numbers,
clock times, durations, counts, bitrates, percentages — so columns align. Headings use slight
negative tracking (`-0.01em`); everything else is normal.
**Spacing & layout.** 4px base grid (2 → 56). Fixed chrome: sidebar `232px`, top bar `52px`,
table/list rows `44px`, controls `34px` (sm `28px`). Layout is a fixed left sidebar + a
compact top bar + a scrolling content region. Page gutter `32px`; card interior `16–24px`;
table cell padding `16px × 10px`. Dense but never cramped — rows breathe at 44px.
**Corners.** Tight and consistent: chips/badges `3px`, buttons/inputs `5px`, cards/rows/menus
`7px`, dialogs `10px`, pills fully round. Nothing is softer than 10px.
**Elevation.** Dark surfaces lean on **border + a subtle drop shadow**, never glow. `sm`
`0 1px 2px/.40`, `md` `0 4px 12px/.45`, popovers `0 8px 28px/.55`, dialogs `0 16px 40px/.55`.
Depth reads mostly through the surface-lightness ladder (bg → surface → raised → selected),
with shadow as a secondary cue. The one exception: live/on-air elements get a small accent
glow (playhead, pulsing status dot).
**Backgrounds.** No images, gradients, textures, or patterns — flat fills only. The single
"illustration" is the chicory-flower mark. Imagery, when present, is user content (channel
logos), shown on a sunken well and contained, never full-bleed.
**Borders & dividers.** 1px hairlines do the structural work: card edges, table row
separators, section splits, input outlines. Selected rows shift background rather than adding a
border; the active nav item adds a 2.5px accent rail on its left edge plus a soft accent tint.
**Motion.** Quick and functional. Durations 90 / 140 / 220ms; standard easing
`cubic-bezier(0.2,0,0,1)`, entrances `cubic-bezier(0.16,1,0.3,1)`. Hover = background/opacity
shift (`~90ms`); press = 0.5px nudge down, no scale bounce. Switches slide (`140ms`). The only
looping animations are meaningful: the live status-dot ping and indeterminate progress/scan
sweeps. Respect `prefers-reduced-motion`.
**States.**
- *Hover:* raise the surface one step (row → `#1C2029`) or lighten ghost controls; text goes
from muted to primary.
- *Active/selected:* accent-soft tint + (nav) accent rail; never a heavy fill.
- *Focus:* 3px accent ring (`--focus-ring`) + accent border on inputs.
- *Disabled:* 40–50% opacity, no pointer.
- *Live/on-air:* accent color + pulsing dot + faint glow.
**Cards.** Flat `#171A21` surface, 1px hairline border, `7px` radius, no outer shadow by
default (shadow only when floating, e.g. menus/dialogs). Optional header row (title + subtitle
left, actions right) separated by a hairline; body padding `16px`, or `0` for flush tables.
**Transparency / blur.** Used sparingly: soft status/accent tints are alpha over the dark
surface; overlay scrims behind dialogs. No frosted-glass blur in the core admin.
### Color schemes (themes)
The system ships **three** interchangeable schemes as CSS theme scopes (`tokens/themes.css`).
Set `data-theme` on `<html>` to switch; each scope overrides only the base `--ctv-*` values, so
every component and semantic alias follows automatically.
- **Warm chicory** (primary, default `:root`) — roasted-amber accent `#E08A3C` + chicory-leaf
green secondary `#6B8F71`; live rides the amber. The signature ChicoryTV look.
- **Cool periwinkle** (`[data-theme="cool"]`) — the cooler, focused alternate; accent `#5B7CFA`.
- **Dual accent** (`[data-theme="dual"]`) — cool periwinkle app, with a warm focal hue `#F0663F`
driving the attention layer: live/on-air state **plus** primary actions, the active nav item, and
selection (wired through the `--action-primary` / `--ctv-live` aliases), so the two accents read
distinctly while warm and cool stay coherent.
The UI kit has a live swatch switcher (bottom-left) to preview all three; the choice persists.
---
## Iconography
- **System:** [**Lucide**](https://lucide.dev) — thin, consistent stroke icons — is the
ChicoryTV icon set. This is a deliberate substitution for MudBlazor's Material Symbols in the
source; Lucide's lighter 2px stroke suits the dark, technical aesthetic far better. Loaded
from CDN (`unpkg.com/lucide`) in cards and the UI kit.
- **Sizes:** 15–16px inside rows/buttons, 13–14px in dense tables/badges, 17–18px in the top
bar. Stroke width stays at Lucide's default (~2). Icons inherit `currentColor` and sit at
`--text-secondary` until hover/active (then `--text-primary` or accent).
- **Common glyphs** (mapped from ErsatzTV actions): `Tv`, `CalendarClock`, `ListVideo`,
`Library`, `FolderTree`, `Settings`, `LayoutDashboard`, `Play`, `Pencil`, `Trash2`,
`RefreshCw`, `Stethoscope`/`Info` (troubleshoot), `Plus`, `Hash`, `Search`, `Radio` (on air),
`Film`, `Sparkles` (filler), `HardDrive`/`Server` (media source).
- **No emoji, no unicode-glyph icons** in the UI. The only unicode marks used are small
affordance triangles on the timeline playhead label.
- **Brand mark:** the chicory flower is an SVG (`assets/chicory-mark.svg`) — 16 radiating
periwinkle petals around a dark center. Do not redraw it; reference the file.
---
## File index
**Foundations**
- `styles.css` — root entry point (only `@import`s). Consumers link this.
- `tokens/fonts.css` — Geist + Geist Mono (Google Fonts).
- `tokens/colors.css` — surfaces, accent, status, text (base + semantic aliases).
- `tokens/typography.css` — families, scale, weights, tracking, tabular-num helper.
- `tokens/spacing.css` — 4px scale + fixed chrome sizes.
- `tokens/radius-elevation-motion.css` — radii, shadows, easings, durations.
- `guidelines/*.card.html` — foundation specimen cards (Colors, Type, Spacing, Brand).
**Assets** (`assets/`)
- `chicory-mark.svg` — flower mark. `chicorytv-wordmark.svg` — mark + wordmark. `chicorytv-icon.svg` — app icon.
**Components** (`components/`) — React primitives, `window.ChicoryTVDesignSystem_eb3b61`
- `forms/` — Button, IconButton, Input, Select, Switch, Checkbox.
- `data-display/` — Badge, StatusDot, Tag, ChannelLogo, Card, Stat, ProgressBar.
- `feedback/` — Toast, Tooltip, Spinner.
- `navigation/` — NavItem + NavSection, Tabs.
- Each folder has a `*.card.html` specimen and each component a `.d.ts` + `.prompt.md`.
**Admin template** (`templates/chicorytv-admin/`) — the reusable admin shell; the single
source of truth for the full app (consuming projects start from it, and it doubles as the
interactive specimen). Open `ChicoryTvAdmin.dc.html` (or `app.html` directly).
- Screens: **Dashboard** (on-air cards, stat tiles, health panel, activity, release notes),
**Channels** (bulk-select, `(D)`/`(H)` markers, inline actions), **New Channel** — the
ground-up *library-to-lineup* Channel Builder (searchable library browser + drag/double-click
to a reorderable lineup + friendly options, Channel Template picker, and an Advanced override
disclosure — replacing the old Channel→Schedule→Items→Playout editor chain), **Schedule
editor** (two-pane, drag-to-reorder), **Guide / EPG grid** (channels × time, now-marker),
plus **Playouts** and **Libraries**, and a live 3-theme switcher.
- `Shell.jsx` (nav + top bar), `*.jsx` screens, `data.js` (mock data), `ds-base.js` (loads the
compiled bundle + stylesheets). See its `README.md` for structure.
**Skill**
- `SKILL.md` — Agent-Skills-compatible entry point for reuse in Claude Code.
+11
View File
@@ -0,0 +1,11 @@
/* ============================================================
ChicoryTV Design System — global entry point.
Consumers link ONLY this file. Import order matters:
fonts first, then tokens.
============================================================ */
@import url("tokens/fonts.css");
@import url("tokens/colors.css");
@import url("tokens/typography.css");
@import url("tokens/spacing.css");
@import url("tokens/radius-elevation-motion.css");
@import url("tokens/themes.css");
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -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,188 @@
// Channels screen — a channel-group'd management table with an at-a-glance
// on-air treatment (accent rail + live now-playing lane) and a strong channel
// identity cell. Dense and scannable — the right tool for managing 128 channels.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Badge, StatusDot, ChannelLogo, Checkbox, Tooltip } = NS;
const Ico = window.Ico;
const D = window.CTV_DATA;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const NOW = {
"1.1": { t: "Tom & Jerry — The Cat Concerto", pct: 48 },
"2.1": { t: "World Tonight — Evening bulletin", pct: 34 },
"4.2": { t: "80s Block — Synthpop hour", pct: 51 },
"9.1": { t: "Sesame Street — Episode 4102", pct: 82 },
};
const th = { textAlign: "left", padding: "0 var(--pad-cell-x)", height: 34, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: "var(--text-disabled)", whiteSpace: "nowrap", position: "sticky", top: 0, background: "var(--surface-card)", borderBottom: "1px solid var(--border-hairline)", zIndex: 2 };
const td = { padding: "0 var(--pad-cell-x)", height: 56, font: "var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)", verticalAlign: "middle" };
const cellCheck = { padding: "0 0 0 14px", width: 30 };
function Marker({ children, title }) {
return <span title={title} style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", minWidth: 15, height: 15, padding: "0 3px", borderRadius: "var(--radius-xs)", border: "1px solid var(--border-control)", font: "var(--weight-semibold) 9px/1 var(--font-mono)", color: "var(--text-disabled)", background: "var(--ctv-surface-3)" }}>{children}</span>;
}
function Channels() {
const [view, setView] = React.useState("all");
const [hover, setHover] = React.useState(null);
const [sel, setSel] = React.useState({});
let rows = D.channels;
if (view === "onair") rows = rows.filter((c) => c.live);
else if (view === "disabled") rows = rows.filter((c) => !c.enabled);
// group, preserving first-seen order
const groups = [];
const gmap = {};
rows.forEach((c) => { if (!gmap[c.group]) { gmap[c.group] = []; groups.push(c.group); } gmap[c.group].push(c); });
const selCount = Object.values(sel).filter(Boolean).length;
const allOn = rows.length > 0 && rows.every((c) => sel[c.num]);
const someOn = selCount > 0 && !allOn;
const toggleAll = () => { if (allOn) setSel({}); else { const n = {}; rows.forEach((c) => (n[c.num] = true)); setSel(n); } };
const toggleOne = (num) => setSel((s) => ({ ...s, [num]: !s[num] }));
const counts = { all: D.channels.length, onair: D.channels.filter((c) => c.live).length, disabled: D.channels.filter((c) => !c.enabled).length };
const Segmented = () => (
<div style={{ display: "inline-flex", gap: 2, padding: 2, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
{[{ v: "all", l: "All", c: counts.all }, { v: "onair", l: "On air", c: counts.onair }, { v: "disabled", l: "Disabled", c: counts.disabled }].map((o) => {
const on = o.v === view;
return (
<button key={o.v} type="button" onClick={() => setView(o.v)}
style={{ display: "inline-flex", alignItems: "center", gap: 6, height: 26, padding: "0 11px", border: "none", borderRadius: "var(--radius-xs)", cursor: "pointer", background: on ? "var(--ctv-surface-3)" : "transparent", color: on ? "var(--text-primary)" : "var(--text-secondary)", font: `${on ? "var(--weight-medium)" : "var(--weight-normal)"} var(--text-xs)/1 var(--font-sans)` }}>
{o.l}<span style={{ ...mono, fontSize: "var(--text-2xs)", color: on && o.v === "onair" ? "var(--status-live)" : "var(--text-disabled)" }}>{o.c}</span>
</button>
);
})}
</div>
);
const renderRow = (c, first) => {
const dim = !c.enabled;
const isSel = sel[c.num];
const isHover = hover === c.num;
const now = NOW[c.num];
const rowBg = isSel ? "var(--ctv-accent-soft)" : isHover ? "var(--ctv-surface-2)" : c.live ? "var(--ctv-live-soft)" : "transparent";
return (
<tr key={c.num} onMouseEnter={() => setHover(c.num)} onMouseLeave={() => setHover(null)}
style={{ borderTop: first ? "none" : "1px solid var(--border-hairline)", background: rowBg, boxShadow: c.live ? "inset 2px 0 0 var(--status-live)" : "none", transition: "background var(--dur-fast)" }}>
<td style={{ ...td, ...cellCheck }}><Checkbox checked={!!isSel} onChange={() => toggleOne(c.num)} /></td>
<td style={{ ...td, ...mono, width: 62 }}>
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, opacity: dim ? 0.55 : 1 }}>
{c.live ? <StatusDot status="live" size={7} /> : <span style={{ width: 7 }} />}
<span style={{ color: "var(--status-live)" }}>{c.num}</span>
</span>
</td>
<td style={{ ...td, opacity: dim ? 0.55 : 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 11 }}>
<ChannelLogo name={c.name} size={34} />
<div style={{ minWidth: 0 }}>
<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)" }}>{c.name}</span>
{!c.enabled && <Marker title="Disabled">D</Marker>}
{!c.epg && <Marker title="Hidden from EPG">H</Marker>}
</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>{c.lang}</div>
</div>
</div>
</td>
<td style={{ ...td, opacity: dim ? 0.55 : 1, width: 250 }}>
{now ? (
<div style={{ minWidth: 0 }}>
<div style={{ font: "var(--text-xs)/1.2 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 220 }}>{now.t}</div>
<div style={{ marginTop: 6, display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ height: 3, width: 150, borderRadius: 999, background: "var(--ctv-surface-3)", overflow: "hidden" }}>
<div style={{ width: now.pct + "%", height: "100%", background: "var(--status-live)" }} />
</div>
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{now.pct}%</span>
</div>
</div>
) : <span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-faint)" }}>{c.enabled ? "Idle" : "Off air"}</span>}
</td>
<td style={{ ...td, opacity: dim ? 0.55 : 1, width: 130 }}><Badge tone={c.mode === "HLS Direct" ? "neutral" : "accent"}>{c.mode}</Badge></td>
<td style={{ ...td, ...mono, color: c.profile === "—" ? "var(--text-disabled)" : "var(--text-secondary)", opacity: dim ? 0.55 : 1, width: 130 }}>{c.profile}</td>
<td style={{ ...td, width: 150 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: 2, opacity: isHover ? 1 : 0.5, transition: "opacity var(--dur-fast)" }}>
<Tooltip label="Preview"><IconButton size="sm" title="Preview" variant={c.live ? "solid" : "ghost"} disabled={!c.live}><Ico n="Play" s={15} /></IconButton></Tooltip>
<Tooltip label="Edit"><IconButton size="sm" title="Edit"><Ico n="Pencil" s={15} /></IconButton></Tooltip>
<Tooltip label="Troubleshoot"><IconButton size="sm" title="Troubleshoot"><Ico n="Stethoscope" s={15} /></IconButton></Tooltip>
<Tooltip label="Delete"><IconButton size="sm" title="Delete"><Ico n="Trash2" s={15} /></IconButton></Tooltip>
</div>
</td>
</tr>
);
};
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* action bar */}
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto", minHeight: 58 }}>
{selCount > 0 ? (
<React.Fragment>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}><span style={mono}>{selCount}</span> selected</span>
<span style={{ width: 1, height: 18, background: "var(--border-hairline)" }} />
<Button size="sm" variant="secondary" startIcon={<Ico n="Hash" s={14} />}>Renumber</Button>
<Button size="sm" variant="secondary" startIcon={<Ico n="FolderInput" s={14} />}>Move to group</Button>
<Button size="sm" variant="danger" startIcon={<Ico n="Trash2" s={14} />}>Delete</Button>
<span style={{ flex: 1 }} />
<Button size="sm" variant="ghost" onClick={() => setSel({})}>Clear</Button>
</React.Fragment>
) : (
<React.Fragment>
<Segmented />
<span style={{ flex: 1 }} />
<span style={{ display: "inline-flex", alignItems: "center", gap: 7, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<StatusDot status="live" size={7} /><span style={mono}>{counts.onair}</span> on air
</span>
<Button variant="primary" startIcon={<Ico n="Plus" s={15} />}>Add Channel</Button>
</React.Fragment>
)}
</div>
<div style={{ flex: 1, overflow: "auto", padding: "16px 20px" }}>
<div style={{ border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden", background: "var(--surface-card)" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead><tr>
<th style={{ ...th, ...cellCheck }}><Checkbox checked={allOn} indeterminate={someOn} onChange={toggleAll} /></th>
<th style={th}>No.</th>
<th style={th}>Channel</th>
<th style={th}>Now playing</th>
<th style={th}>Streaming</th>
<th style={th}>FFmpeg</th>
<th style={{ ...th, textAlign: "right" }}></th>
</tr></thead>
<tbody>
{groups.map((g) => (
<React.Fragment key={g}>
<tr>
<td colSpan={7} style={{ padding: "0 var(--pad-cell-x)", height: 32, background: "var(--ctv-bg-sunken)", borderTop: "1px solid var(--border-hairline)", borderBottom: "1px solid var(--border-hairline)" }}>
<span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
<Ico n="Folder" s={13} color="var(--text-disabled)" />
<span style={{ font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.05em", textTransform: "uppercase", color: "var(--text-secondary)" }}>{g}</span>
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{gmap[g].length}</span>
</span>
</td>
</tr>
{gmap[g].map((c, i) => renderRow(c, i === 0))}
</React.Fragment>
))}
</tbody>
</table>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 16px", borderTop: "1px solid var(--border-hairline)", font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<span><span style={mono}>{rows.length}</span> of <span style={mono}>128</span> channels · <span style={mono}>{groups.length}</span> groups</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<IconButton size="sm" title="Prev"><Ico n="ChevronLeft" s={16} /></IconButton>
<span style={{ ...mono, fontSize: "var(--text-xs)", padding: "0 6px", color: "var(--text-secondary)" }}>1 / 12</span>
<IconButton size="sm" title="Next"><Ico n="ChevronRight" s={16} /></IconButton>
</span>
</div>
</div>
</div>
</div>
);
}
window.CTVChannels = Channels;
})();
@@ -0,0 +1,19 @@
<!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>
<!-- @template name="ChicoryTV Admin" description="Dark IPTV channel-server admin shell — sidebar nav, Dashboard, Channels, Guide (EPG), Schedule editor, Playouts, Libraries, a Connect menu, and a 3-theme switcher (warm chicory primary). Built on the ChicoryTV component library." -->
<helmet>
<style>
html, body { margin: 0; height: 100%; background: #12100E; }
</style>
</helmet>
<iframe src="./app.html" title="ChicoryTV Admin" style="width:100vw;height:100vh;border:0;display:block;background:#12100E"></iframe>
</x-dc>
</body>
</html>
@@ -0,0 +1,145 @@
// Dashboard — spec 4.1: On-air-now cards, stat tiles, Health panel,
// Recent activity, collapsible Release notes.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Card, Stat, Badge, StatusDot, ChannelLogo, ProgressBar, IconButton } = NS;
const Ico = window.Ico;
const D = window.CTV_DATA;
const toneColor = { accent: "var(--ctv-accent)", ok: "var(--status-ok)", warn: "var(--status-warn)", error: "var(--status-error)" };
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
// health status -> icon + color
const HEALTH = {
pass: { icon: "Check", color: "var(--status-ok)", label: "Pass" },
info: { icon: "Info", color: "var(--ctv-accent)", label: "Info" },
warning: { icon: "TriangleAlert", color: "var(--status-warn)", label: "Warning" },
fail: { icon: "CircleX", color: "var(--status-error)", label: "Fail" },
};
function OnAirCard({ ch, program, pct, remain }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10, padding: 12, border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", background: "var(--ctv-bg-sunken)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<ChannelLogo name={ch.name} size={34} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ ...mono, font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)", color: "var(--status-live)" }}>{ch.num}</span>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{ch.name}</span>
</div>
</div>
<StatusDot status="live" />
</div>
<div style={{ font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{program}</div>
<div>
<ProgressBar value={pct} tone="accent" height={4} />
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 6, ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>
<span>{pct}%</span>
<span>{remain} to next</span>
</div>
</div>
</div>
);
}
function ReleaseNotes() {
const [open, setOpen] = React.useState(false);
const rn = D.releaseNotes;
return (
<div style={{ background: "var(--surface-card)", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
<button type="button" onClick={() => setOpen((o) => !o)}
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "13px 16px", border: "none", background: "transparent", cursor: "pointer", textAlign: "left" }}>
<Ico n="Sparkles" s={15} color="var(--text-secondary)" />
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>What's new</span>
<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>{rn.version} · {rn.date}</span>
<span style={{ flex: 1 }} />
<Ico n={open ? "ChevronUp" : "ChevronDown"} s={16} color="var(--text-disabled)" />
</button>
{open && (
<ul style={{ margin: 0, padding: "4px 18px 16px 34px", display: "flex", flexDirection: "column", gap: 7 }}>
{rn.items.map((it, i) => (
<li key={i} style={{ font: "var(--text-xs)/1.45 var(--font-sans)", color: "var(--text-secondary)" }}>{it}</li>
))}
</ul>
)}
</div>
);
}
function Dashboard() {
const onAir = D.channels.filter((c) => c.live).slice(0, 4);
const progs = [
{ program: "Now: Looney Tunes — Rabbit Fire", pct: 68, remain: "07:41" },
{ program: "Now: World Tonight — Evening bulletin", pct: 34, remain: "19:22" },
{ program: "Now: 80s Block — Synthpop hour", pct: 51, remain: "14:08" },
{ program: "Now: Sesame Street — Episode 4102", pct: 82, remain: "05:12" },
];
const failCount = D.health.filter((h) => h.status === "fail").length;
const warnCount = D.health.filter((h) => h.status === "warning").length;
return (
<div style={{ padding: 20, display: "flex", flexDirection: "column", gap: 16 }}>
{/* stat tiles */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(4,1fr)", gap: 14 }}>
<Card><Stat label="Channels" value="128" icon={<Ico n="Tv" s={15} />} /></Card>
<Card><Stat label="Active playouts" value="112" icon={<Ico n="ListVideo" s={15} />} /></Card>
<Card><Stat label="Transcodes" value="4" icon={<Ico n="Radio" s={15} />} delta="live" deltaTone="neutral" /></Card>
<Card><Stat label="Libraries" value="5" icon={<Ico n="Library" s={15} />} /></Card>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1.55fr 1fr", gap: 16, alignItems: "start" }}>
{/* On air now */}
<Card title="On air now" subtitle={`${onAir.length} channels streaming`} actions={<Badge tone="accent" dot>Live</Badge>}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
{onAir.map((c, i) => <OnAirCard key={c.num} ch={c} {...progs[i]} />)}
</div>
</Card>
{/* Health panel */}
<Card title="System health" padded={false}
actions={<span style={{ display: "inline-flex", gap: 6 }}>
{failCount > 0 && <Badge tone="error" dot>{failCount} fail</Badge>}
{warnCount > 0 && <Badge tone="warn" dot>{warnCount} warn</Badge>}
</span>}>
<div>
{D.health.map((h, i) => {
const s = HEALTH[h.status];
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 10, padding: "9px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none", cursor: "pointer" }}>
<Ico n={s.icon} s={15} color={s.color} />
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)", flex: "0 0 auto" }}>{h.title}</span>
<span style={{ font: "var(--text-xs)/1.3 var(--font-sans)", color: "var(--text-disabled)", flex: 1, textAlign: "right", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{h.detail}</span>
<Ico n="ChevronRight" s={14} color="var(--text-faint)" />
</div>
);
})}
</div>
</Card>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1.55fr 1fr", gap: 16, alignItems: "start" }}>
{/* Recent activity */}
<Card title="Recent activity" padded={false}>
<div>
{D.activity.map((a, i) => (
<div key={i} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 24, height: 24, flex: "0 0 auto", borderRadius: "var(--radius-xs)", background: "var(--ctv-surface-3)", color: toneColor[a.tone] }}>
<Ico n={a.icon} s={13} />
</span>
<span style={{ flex: 1, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>{a.text}</span>
<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)", whiteSpace: "nowrap", marginTop: 2 }}>{a.time}</span>
</div>
))}
</div>
</Card>
{/* Release notes (collapsible, secondary) */}
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<ReleaseNotes />
</div>
</div>
</div>
);
}
window.CTVDashboard = Dashboard;
})();
@@ -0,0 +1,108 @@
// EPG / Guide grid — spec 4.4: channels down the left, time across the top,
// programme blocks, a vertical "now" marker, current programme uses the focal
// (live) color, filler muted, jump-to-now + time scrubber.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Badge, Select, ChannelLogo } = NS;
const Ico = window.Ico;
const D = window.CTV_DATA;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const SLOT_W = 156; // px per 30-min slot
const RAIL_W = 196; // channel rail
const ROW_H = 82;
const HEAD_H = 34;
const NOW_SLOTS = 31 / 30; // 20:31 → just past the first slot
const catTone = { Filler: "var(--text-disabled)" };
function Programme({ p }) {
const filler = p.filler;
const live = p.live;
return (
<div title={`${p.title}${p.sub ? " — " + p.sub : ""}`}
style={{
position: "absolute",
left: p.offset * SLOT_W + 3,
width: p.span * SLOT_W - 6,
top: 5, bottom: 5,
borderRadius: "var(--radius-sm)",
padding: "8px 10px",
overflow: "hidden",
background: live ? "var(--ctv-live-soft)" : filler ? "var(--ctv-bg-sunken)" : "var(--ctv-surface-2)",
border: `1px solid ${live ? "var(--status-live)" : "var(--border-hairline)"}`,
display: "flex", flexDirection: "column", gap: 3,
}}>
<div style={{ display: "flex", alignItems: "center", gap: 6, minWidth: 0 }}>
{live && <span style={{ width: 6, height: 6, borderRadius: "50%", background: "var(--status-live)", flex: "0 0 auto" }} />}
<span style={{ font: "var(--weight-medium) var(--text-xs)/1.2 var(--font-sans)", color: filler ? "var(--text-disabled)" : "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.title}</span>
</div>
{p.sub && p.span > 1 && (
<span style={{ font: "var(--text-2xs)/1.2 var(--font-sans)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{p.sub}</span>
)}
{p.span > 1 && (
<span style={{ marginTop: "auto", font: "var(--weight-medium) 9px/1 var(--font-sans)", letterSpacing: "0.04em", textTransform: "uppercase", color: live ? "var(--status-live)" : "var(--text-disabled)" }}>{p.cat}</span>
)}
</div>
);
}
function Epg() {
const [filter, setFilter] = React.useState("All channels");
const totalW = RAIL_W + D.epgSlots.length * SLOT_W;
const nowX = RAIL_W + NOW_SLOTS * SLOT_W;
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* toolbar */}
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
<div style={{ width: 190 }}>
<Select size="sm" value={filter} onChange={(e) => setFilter(e.target.value)} options={["All channels", "Favorites", "News", "Kids", "Movies"]} />
</div>
<div style={{ flex: 1, display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>20:00</span>
<input type="range" min="0" max="100" defaultValue="12" style={{ flex: 1, accentColor: "var(--ctv-accent)", maxWidth: 320 }} />
<span style={{ ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>02:00</span>
</div>
<Badge tone="accent" dot>Now 20:31</Badge>
<Button variant="primary" startIcon={<Ico n="Crosshair" s={15} />}>Jump to now</Button>
</div>
{/* grid */}
<div style={{ flex: 1, minHeight: 0, overflow: "auto", background: "var(--surface-app)" }}>
<div style={{ position: "relative", width: totalW, minWidth: "100%" }}>
{/* time header */}
<div style={{ display: "flex", position: "sticky", top: 0, zIndex: 3, height: HEAD_H, background: "var(--surface-app)", borderBottom: "1px solid var(--border-hairline)" }}>
<div style={{ width: RAIL_W, flex: "0 0 auto", position: "sticky", left: 0, zIndex: 4, background: "var(--surface-app)", borderRight: "1px solid var(--border-hairline)" }} />
{D.epgSlots.map((t, i) => (
<div key={i} style={{ width: SLOT_W, flex: "0 0 auto", display: "flex", alignItems: "center", padding: "0 10px", borderRight: "1px solid var(--border-hairline)", ...mono, fontSize: "var(--text-xs)", color: "var(--text-secondary)" }}>{t}</div>
))}
</div>
{/* now marker */}
<div style={{ position: "absolute", top: HEAD_H, bottom: 0, left: nowX, width: 2, background: "var(--status-live)", zIndex: 2, pointerEvents: "none" }}>
<span style={{ position: "absolute", top: -6, left: -4, width: 10, height: 10, borderRadius: "50%", background: "var(--status-live)", boxShadow: "0 0 8px var(--status-live)" }} />
</div>
{/* channel rows */}
{D.epg.map((ch, i) => (
<div key={ch.num} style={{ display: "flex", height: ROW_H, borderBottom: "1px solid var(--border-hairline)" }}>
{/* rail */}
<div style={{ width: RAIL_W, flex: "0 0 auto", position: "sticky", left: 0, zIndex: 1, display: "flex", alignItems: "center", gap: 10, padding: "0 12px", background: "var(--surface-card)", borderRight: "1px solid var(--border-hairline)" }}>
<span style={{ ...mono, font: "var(--weight-medium) var(--text-xs)/1 var(--font-mono)", color: "var(--status-live)", width: 26, flex: "0 0 auto" }}>{ch.num}</span>
<ChannelLogo name={ch.name} size={30} />
<span style={{ font: "var(--weight-medium) var(--text-sm)/1.2 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{ch.name}</span>
</div>
{/* programme track */}
<div style={{ position: "relative", flex: 1, background: i % 2 ? "rgba(255,255,255,0.008)" : "transparent" }}>
{ch.programmes.map((p, j) => <Programme key={j} p={p} />)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
window.CTVEpg = Epg;
})();
@@ -0,0 +1,204 @@
// Playouts screen — GROUND-UP: a broadcast "now / next" monitor. Channel picker
// strip, live preview + progress, up-next, the playout timeline, and upcoming items.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Card, Button, IconButton, Badge, StatusDot, ChannelLogo, Switch, Tooltip, Input } = NS;
const Ico = window.Ico;
const D = window.CTV_DATA;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const toMin = (t) => { const [h, m] = t.split(":").map(Number); return h * 60 + m; };
function Timeline() {
const items = D.playoutItems;
const start = toMin(items[0].start), end = toMin(items[items.length - 1].finish);
const span = end - start, nowPct = 34;
return (
<div>
<div style={{ position: "relative", display: "flex", height: 34, borderRadius: "var(--radius-sm)", overflow: "hidden", border: "1px solid var(--border-hairline)" }}>
{items.map((it, i) => {
const w = ((toMin(it.finish) - toMin(it.start)) / span) * 100;
const filler = it.kind === "filler";
return (
<div key={i} title={it.title} style={{ width: `${w}%`, minWidth: 2, borderRight: i < items.length - 1 ? "1px solid var(--ctv-bg)" : "none", background: filler ? "var(--ctv-surface-3)" : "var(--ctv-accent-soft)", display: "flex", alignItems: "center", padding: "0 8px", overflow: "hidden" }}>
{!filler && w > 8 && <span style={{ font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)", color: "var(--ctv-accent)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{it.title.split(" — ")[0]}</span>}
</div>
);
})}
<div style={{ position: "absolute", top: -3, bottom: -3, left: `${nowPct}%`, width: 2, background: "var(--status-live)", boxShadow: "0 0 8px var(--status-live)" }}>
<span style={{ position: "absolute", top: -4, left: -3, width: 8, height: 8, borderRadius: "50%", background: "var(--status-live)" }} />
</div>
</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 6, ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>
<span>{D.playoutItems[0].start.slice(0, 5)}</span>
<span style={{ color: "var(--status-live)" }}>▲ now 20:31</span>
<span>{D.playoutItems[D.playoutItems.length - 1].finish.slice(0, 5)}</span>
</div>
</div>
);
}
function Playouts() {
const all = D.channels;
const [selNum, setSelNum] = React.useState("1.1");
const [q, setQ] = React.useState("");
const sel = D.channels.find((c) => c.num === selNum) || all[0];
const now = D.playoutItems[2]; // Tom & Jerry
const next = D.playoutItems[3];
const NOW = {
"1.1": "Tom & Jerry — The Cat Concerto", "2.1": "World Tonight", "4.2": "80s Block — Synthpop hour", "9.1": "Sesame Street",
};
const ql = q.trim().toLowerCase();
const rail = all.filter((c) => c.enabled && (!ql || c.name.toLowerCase().includes(ql) || c.num.includes(ql)));
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* header */}
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
<Button variant="secondary" startIcon={<Ico n="RefreshCw" s={15} />}>Reset All</Button>
<span style={{ flex: 1 }} />
<Badge tone="warn" dot>3 warnings</Badge>
<Button variant="primary" startIcon={<Ico n="Plus" s={15} />} endIcon={<Ico n="ChevronDown" s={14} />}>Add Playout</Button>
</div>
<div style={{ flex: 1, minHeight: 0, display: "flex" }}>
{/* LEFT — searchable channel rail (scales to 128, no horizontal scroll) */}
<div style={{ width: 248, flex: "0 0 auto", display: "flex", flexDirection: "column", borderRight: "1px solid var(--border-hairline)", minHeight: 0 }}>
<div style={{ padding: "12px 12px 10px", borderBottom: "1px solid var(--border-hairline)" }}>
<Input size="sm" leadingIcon={<Ico n="Search" s={15} />} placeholder="Filter playouts…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div style={{ flex: 1, overflow: "auto", padding: 8 }}>
{rail.map((c) => {
const active = c.num === selNum;
const nowT = NOW[c.num];
return (
<button key={c.num} type="button" onClick={() => setSelNum(c.num)}
style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", padding: "8px 9px", marginBottom: 2, border: "none", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left",
background: active ? "var(--ctv-accent-soft)" : "transparent" }}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = "var(--ctv-surface-2)"; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = "transparent"; }}>
<ChannelLogo name={c.name} size={30} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ ...mono, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-mono)", color: "var(--status-live)" }}>{c.num}</span>
<span style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{c.name}</span>
</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{c.live ? nowT || "On air" : "Idle"}
</div>
</div>
{c.live && <StatusDot status="live" size={6} />}
</button>
);
})}
</div>
</div>
{/* RIGHT — monitor for the selected playout */}
<div style={{ flex: 1, minWidth: 0, minHeight: 0, overflow: "auto" }}>
<div style={{ padding: 20, display: "flex", flexDirection: "column", gap: 16 }}>
{/* selected channel header */}
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<ChannelLogo name={sel.name} size={38} />
<div style={{ flex: 1 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ ...mono, font: "var(--weight-medium) var(--text-sm)/1 var(--font-mono)", color: "var(--status-live)" }}>{sel.num}</span>
<span style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>{sel.name}</span>
{sel.live && <Badge tone="accent" dot>On air</Badge>}
</div>
</div>
</div>
{/* now / next hero */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{/* NOW */}
<div style={{ display: "flex", alignItems: "stretch", background: "var(--surface-card)", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
{/* preview frame */}
<div style={{ position: "relative", width: 208, flex: "0 0 auto", background: "linear-gradient(135deg, #12151C, #0B0D11)", display: "flex", alignItems: "center", justifyContent: "center", borderRight: "1px solid var(--border-hairline)" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 46, height: 46, borderRadius: "50%", background: "rgba(91,124,250,0.15)", border: "1px solid rgba(91,124,250,0.4)", color: "var(--ctv-accent)" }}><Ico n="Play" s={20} /></span>
<span style={{ position: "absolute", top: 12, left: 12, display: "inline-flex", alignItems: "center", gap: 6, padding: "4px 9px", borderRadius: "var(--radius-xs)", background: "rgba(15,17,21,0.6)", border: "1px solid var(--border-hairline)" }}>
<StatusDot status="live" size={7} /><span style={{ font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", color: "var(--text-primary)" }}>LIVE</span>
</span>
<span style={{ position: "absolute", bottom: 12, right: 12, ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-secondary)", background: "rgba(15,17,21,0.6)", padding: "3px 7px", borderRadius: "var(--radius-xs)" }}>20:31:24</span>
</div>
<div style={{ flex: 1, minWidth: 0, padding: 18, display: "flex", flexDirection: "column", justifyContent: "center" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--status-live)" }}>On air now</span>
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{sel.num} · {sel.name} · {sel.profile}</span>
</div>
<div style={{ marginTop: 8, font: "var(--weight-semibold) var(--text-lg)/1.25 var(--font-sans)", color: "var(--text-primary)" }}>{now.title}</div>
<div style={{ marginTop: 14, height: 5, borderRadius: 999, background: "var(--ctv-surface-3)", overflow: "hidden" }}>
<div style={{ width: "48%", height: "100%", background: "var(--status-live)" }} />
</div>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 7, ...mono, fontSize: "var(--text-2xs)", color: "var(--text-disabled)" }}>
<span>{now.start} · started 11m ago</span>
<span>13m to next</span>
</div>
</div>
</div>
{/* NEXT + PLAYOUT */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<Card title="Up next">
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 42, height: 42, borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", color: "var(--text-secondary)", flex: "0 0 auto" }}><Ico n="Film" s={18} /></span>
<div style={{ minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-sm)/1.3 var(--font-sans)", color: "var(--text-primary)" }}>{next.title}</div>
<div style={{ marginTop: 4, ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>starts {next.start} · {next.dur}</div>
</div>
</div>
</Card>
<Card title="Playout">
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<Row label="Source" value="Block Schedule" />
<Row label="Schedule" value="Prime Time Cartoons" />
<Row label="Rebuilds" value="daily · 04:00" />
<div style={{ display: "flex", gap: 8, marginTop: 2 }}>
<Button size="sm" variant="secondary" startIcon={<Ico n="RefreshCw" s={13} />}>Reset</Button>
<Button size="sm" variant="ghost" startIcon={<Ico n="Clock" s={13} />}>Schedule reset</Button>
</div>
</div>
</Card>
</div>
</div>
{/* timeline */}
<Card title="Timeline" subtitle="Tonight's playout">
<Timeline />
</Card>
{/* upcoming */}
<Card title="Upcoming" subtitle="Next items" padded={false}
actions={<Switch checked size="sm" label="Show filler" onChange={() => {}} />}>
<div>
{D.playoutItems.slice(2, 7).map((it, i) => {
const filler = it.kind === "filler";
const isNow = i === 0;
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 16px", borderTop: i ? "1px solid var(--border-hairline)" : "none", background: isNow ? "var(--ctv-live-soft)" : "transparent" }}>
<span style={{ ...mono, font: "var(--text-sm)/1 var(--font-mono)", color: isNow ? "var(--status-live)" : "var(--text-secondary)", width: 66, flex: "0 0 auto" }}>{it.start}</span>
{isNow ? <StatusDot status="live" size={7} /> : <Ico n={filler ? "Sparkles" : "Film"} s={13} color={filler ? "var(--text-disabled)" : "var(--ctv-accent)"} />}
<span style={{ flex: 1, font: "var(--text-sm)/1.3 var(--font-sans)", color: filler ? "var(--text-secondary)" : "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{it.title}</span>
{filler && <Badge tone="neutral">Filler</Badge>}
<span style={{ ...mono, font: "var(--text-xs)/1 var(--font-mono)", color: "var(--text-disabled)", flex: "0 0 auto" }}>{it.dur}</span>
</div>
);
})}
</div>
</Card>
</div>
</div>
</div>
</div>
);
}
function Row({ label, value }) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{label}</span>
<span style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{value}</span>
</div>
);
}
window.CTVPlayouts = Playouts;
})();
@@ -0,0 +1,245 @@
// Schedule editor — GROUND-UP redesign as a visual "lineup builder".
// Real ErsatzTV fields/vocabulary, but a block-timeline composition model with a
// tabbed inspector — replacing the inherited table + 25-field stacked form.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Card, Button, IconButton, Badge, Select, Input, Switch, Checkbox, Tabs, ChannelLogo, Tooltip } = NS;
const Ico = window.Ico;
const D = window.CTV_DATA;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const COLL_TYPES = ["Collection", "Television Show", "Season", "Artist", "Multi Collection", "Smart Collection", "Search Query", "Playlist", "Rerun (First Run)", "Rerun (Rerun)"];
const ORDERS = ["Chronological", "Random", "Shuffle", "Shuffle In Order", "Marathon", "Season/Episode", "Multi-Episode Shuffle"];
const MODES = ["Flood", "One", "Multiple", "Duration"];
const ORDER_ICON = { "Chronological": "ArrowDownWideNarrow", "Random": "Dices", "Shuffle": "Shuffle", "Shuffle In Order": "ListOrdered", "Marathon": "Flame", "Season/Episode": "ListTree", "Multi-Episode Shuffle": "Shuffle" };
// Fill-mode visual descriptor
function fillOf(it) {
if (it.mode === "Flood") return { icon: "MoveVertical", label: "Fills to next", grow: true };
if (it.mode === "One") return { icon: "Square", label: "1 item" };
if (it.mode === "Multiple") return { icon: "Copy", label: "×" + it.count };
if (it.mode === "Duration") return { icon: "Timer", label: it.durEst.replace("~", "") };
return { icon: "Square", label: it.mode };
}
function Block({ it, active, onClick, dragging, isOver, dragProps }) {
const fill = fillOf(it);
const filler = it.guide === "Filler";
return (
<div onClick={onClick} {...dragProps}
style={{
position: "relative", display: "flex", flexDirection: "column", gap: 8,
padding: "12px 13px", borderRadius: "var(--radius-md)", cursor: "pointer",
background: active ? "var(--ctv-accent-soft)" : "var(--surface-card)",
border: `1px solid ${active ? "rgba(91,124,250,0.45)" : "var(--border-hairline)"}`,
boxShadow: isOver ? "0 -2px 0 var(--ctv-accent)" : "none",
opacity: dragging ? 0.4 : 1,
transition: "background var(--dur-fast), border-color var(--dur-fast)",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 11 }}>
<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={{ position: "relative", flex: "0 0 auto" }}>
<ChannelLogo name={it.collection} size={38} radius="var(--radius-sm)" />
{filler && <span style={{ position: "absolute", inset: 0, borderRadius: "var(--radius-sm)", background: "rgba(15,17,21,0.45)" }} />}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{it.collection}</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{it.collType}</div>
</div>
{/* fill-mode visual */}
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, flex: "0 0 auto", padding: "5px 9px", borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
<Ico n={fill.icon} s={13} color="var(--text-secondary)" />
<span style={{ ...(fill.label.startsWith("×") || /\d/.test(fill.label) ? mono : {}), font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{fill.label}</span>
</span>
</div>
{/* meta chips */}
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap", paddingLeft: 27 }}>
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<Ico n={ORDER_ICON[it.order] || "Shuffle"} s={13} color="var(--text-disabled)" />{it.order}
</span>
{filler ? <Badge tone="neutral">Hidden from guide</Badge> : null}
{(it.pre || it.mid || it.post) && (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
<Ico n="Sparkles" s={12} />
{[it.pre && "pre", it.mid && "mid", it.post && "post"].filter(Boolean).join(" · ")}
</span>
)}
<span style={{ flex: 1 }} />
<span style={{ ...mono, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{it.durEst}</span>
</div>
</div>
);
}
function Inspector({ sel, idx }) {
const [tab, setTab] = React.useState("content");
const fill = fillOf(sel);
return (
<div style={{ background: "var(--surface-card)", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", display: "flex", flexDirection: "column", minHeight: 0, height: "100%" }}>
{/* summary header */}
<div style={{ padding: "14px 16px", borderBottom: "1px solid var(--border-hairline)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<ChannelLogo name={sel.collection} size={34} radius="var(--radius-sm)" />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1.2 var(--font-sans)", color: "var(--text-primary)" }}>{sel.collection}</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Item {idx + 1} · {sel.collType}</div>
</div>
<IconButton title="Remove item"><Ico n="Trash2" s={15} /></IconButton>
</div>
<div style={{ marginTop: 10, display: "flex", gap: 6, flexWrap: "wrap" }}>
<Badge tone={sel.startType === "Fixed" ? "accent" : "neutral"} dot>{sel.startType === "Fixed" ? sel.start : "Dynamic"}</Badge>
<Badge tone="neutral">{sel.order}</Badge>
<Badge tone="neutral">{fill.label === "1 item" ? "One" : sel.mode}{sel.mode === "Multiple" ? " " + fill.label : ""}</Badge>
</div>
</div>
<div style={{ padding: "0 16px" }}>
<Tabs value={tab} onChange={setTab} tabs={[
{ value: "content", label: "Content" },
{ value: "playback", label: "Playback" },
{ value: "filler", label: "Filler" },
{ value: "overrides", label: "Overrides" },
]} />
</div>
<div style={{ flex: 1, overflow: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 14 }}>
{tab === "content" && (
<React.Fragment>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<Select label="Start type" value={sel.startType} onChange={() => {}} options={["Dynamic", "Fixed"]} />
<Input label="Start time" value={sel.startType === "Fixed" ? sel.start : ""} placeholder="dynamic" onChange={() => {}} />
</div>
<Select label="Collection type" value={sel.collType} onChange={() => {}} options={COLL_TYPES} />
<Input label="Collection" value={sel.collection} onChange={() => {}} />
<Input label="Custom title" placeholder="Optional — shown in the guide" onChange={() => {}} />
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12, alignItems: "center" }}>
<Select label="Guide mode" value={sel.guide} onChange={() => {}} options={["Normal", "Filler"]} />
<div style={{ paddingTop: 18 }}><Checkbox checked label="Keep multi-part together" onChange={() => {}} /></div>
</div>
</React.Fragment>
)}
{tab === "playback" && (
<React.Fragment>
<Select label="Playback order" value={sel.order} onChange={() => {}} options={ORDERS} />
<Select label="Playout mode" value={sel.mode} onChange={() => {}} options={MODES} />
{sel.mode === "Multiple" && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<Select label="Multiple mode" value="Count" onChange={() => {}} options={["Count", "Collection Size", "Multi-Episode Group Size"]} />
<Input label="Multiple count" value={String(sel.count)} onChange={() => {}} />
</div>
)}
{sel.mode === "Duration" && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<Input label="Hours" value="1" onChange={() => {}} />
<Input label="Minutes" value="30" onChange={() => {}} />
</div>
)}
<div style={{ padding: "12px 13px", borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", display: "flex", alignItems: "center", gap: 10 }}>
<Ico n={fill.icon} s={16} color="var(--ctv-accent)" />
<span style={{ font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>
{sel.mode === "Flood" ? "Plays until the next fixed-start item." : sel.mode === "One" ? "Plays exactly one item, then advances." : sel.mode === "Multiple" ? `Plays ${sel.count} items, then advances.` : "Plays for a fixed duration."}
</span>
</div>
</React.Fragment>
)}
{tab === "filler" && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<Select label="Pre-roll" value={sel.pre ? "Retro Bumpers" : "None"} onChange={() => {}} options={["None", "Retro Bumpers", "Station IDs"]} />
<Select label="Mid-roll" value={sel.mid ? "Ad Break" : "None"} onChange={() => {}} options={["None", "Ad Break", "Promos"]} />
<Select label="Post-roll" value={sel.post ? "Outro" : "None"} onChange={() => {}} options={["None", "Outro"]} />
<Select label="Tail" value="None" onChange={() => {}} options={["None", "Test Pattern"]} />
<Select label="Fallback" value="Test Pattern" onChange={() => {}} options={["None", "Test Pattern", "Color Bars"]} />
</div>
)}
{tab === "overrides" && (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
<Select label="Watermark" value="Channel logo" onChange={() => {}} options={["Inherit", "Channel logo", "None"]} />
<Select label="Subtitle mode" value="Any" onChange={() => {}} options={["None", "Any", "Forced", "Default"]} />
<Select label="Preferred audio" value="English" onChange={() => {}} options={["English", "Spanish", "French"]} />
<Select label="Preferred subtitle" value="None" onChange={() => {}} options={["None", "English", "Spanish"]} />
</div>
)}
</div>
</div>
);
}
function ScheduleEditor() {
const [items, setItems] = React.useState(D.scheduleItems);
const [selId, setSelId] = React.useState(D.scheduleItems[0].id);
const [dragId, setDragId] = React.useState(null);
const [overId, setOverId] = React.useState(null);
const sel = items.find((i) => i.id === selId) || items[0];
const selIdx = items.findIndex((i) => i.id === sel.id);
const onDrop = (targetId) => {
if (dragId == null || dragId === targetId) { setDragId(null); setOverId(null); return; }
const from = items.findIndex((i) => i.id === dragId);
const to = items.findIndex((i) => i.id === targetId);
const next = items.slice();
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
setItems(next); setDragId(null); setOverId(null);
};
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* header */}
<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)" }}><Ico n="CalendarClock" s={18} /></span>
<div style={{ flex: 1 }}>
<div style={{ font: "var(--weight-semibold) var(--text-md)/1 var(--font-sans)", color: "var(--text-primary)" }}>Prime Time Cartoons</div>
<div style={{ marginTop: 3, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>Block schedule · programs <span style={mono}>~3h 46m</span> · loops to fill the block</div>
</div>
<Button variant="secondary" startIcon={<Ico n="Play" s={15} />}>Preview playout</Button>
<Button variant="primary" startIcon={<Ico n="Check" s={15} />}>Save</Button>
</div>
{/* body */}
<div style={{ flex: 1, minHeight: 0, display: "grid", gridTemplateColumns: "minmax(420px, 1fr) minmax(360px, 460px)", gap: 18, padding: 20 }}>
{/* LEFT lineup on a time rail */}
<div style={{ display: "flex", flexDirection: "column", minHeight: 0 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 12 }}>
<span style={{ font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" }}>Lineup · drag to reorder</span>
<Button size="sm" variant="ghost" startIcon={<Ico n="Plus" s={14} />}>Add item</Button>
</div>
<div style={{ flex: 1, overflow: "auto", paddingRight: 4 }}>
{items.map((it, idx) => {
const isFixed = it.startType === "Fixed";
return (
<div key={it.id} style={{ display: "flex", gap: 12 }}>
{/* time rail */}
<div style={{ width: 52, flex: "0 0 auto", display: "flex", flexDirection: "column", alignItems: "center", position: "relative" }}>
{idx > 0 && <span style={{ position: "absolute", top: 0, height: 16, width: 2, background: "var(--border-hairline)" }} />}
<div style={{ marginTop: isFixed ? 14 : 16, display: "flex", flexDirection: "column", alignItems: "center", gap: 4 }}>
<span style={{ width: 9, height: 9, borderRadius: "50%", background: isFixed ? "var(--ctv-accent)" : "transparent", border: `2px solid ${isFixed ? "var(--ctv-accent)" : "var(--border-control)"}` }} />
{isFixed
? <span style={{ ...mono, font: "var(--weight-medium) var(--text-2xs)/1 var(--font-mono)", color: "var(--ctv-accent)" }}>{it.start}</span>
: <span style={{ font: "9px/1 var(--font-sans)", color: "var(--text-faint)" }}>flows</span>}
</div>
{idx < items.length - 1 && <span style={{ position: "absolute", bottom: -12, top: 40, width: 2, background: "var(--border-hairline)" }} />}
</div>
{/* block */}
<div style={{ flex: 1, minWidth: 0, paddingBottom: 12 }}>
<Block it={it} active={it.id === selId} onClick={() => setSelId(it.id)}
dragging={it.id === dragId} isOver={it.id === overId && dragId != null && dragId !== it.id}
dragProps={{
draggable: true,
onDragStart: () => setDragId(it.id),
onDragEnd: () => { setDragId(null); setOverId(null); },
onDragOver: (e) => { e.preventDefault(); setOverId(it.id); },
onDrop: () => onDrop(it.id),
}} />
</div>
</div>
);
})}
</div>
</div>
{/* RIGHT inspector */}
<Inspector sel={sel} idx={selIdx} />
</div>
</div>
);
}
window.CTVScheduleEditor = ScheduleEditor;
})();
@@ -0,0 +1,117 @@
// Libraries screen — GROUND-UP: media sources as connection cards (Local / Plex /
// Jellyfin / Emby), each listing its libraries with live scan state — a modern
// "connections" surface, not a flat table.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { Button, IconButton, Badge, StatusDot, ProgressBar, Tooltip } = NS;
const Ico = window.Ico;
const D = window.CTV_DATA;
const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
const KIND = {
Local: { icon: "HardDrive", tint: "var(--ctv-accent)" },
Plex: { icon: "Server", tint: "#E5A00D" },
Jellyfin: { icon: "Server", tint: "#8A5CF6" },
Emby: { icon: "Server", tint: "#52B54B" },
};
const LIB_STATUS = {
ok: { tone: "ok", label: "Synced" },
warn: { tone: "warn", label: "Needs attention" },
scan: { tone: "accent", label: "Scanning" },
};
const MEDIA_ICON = { "Movies": "Clapperboard", "Shows": "MonitorPlay", "Other Videos": "Video", "Music Videos": "Music" };
function SourceCard({ src }) {
const k = KIND[src.kind] || KIND.Local;
const totalItems = src.libs.reduce((a, l) => a + l.items, 0);
const scanning = src.libs.some((l) => l.status === "scan");
return (
<div style={{ display: "flex", flexDirection: "column", background: "var(--surface-card)", border: "1px solid var(--border-hairline)", borderRadius: "var(--radius-md)", overflow: "hidden" }}>
{/* source header */}
<div style={{ display: "flex", alignItems: "center", gap: 11, padding: "13px 15px", borderBottom: "1px solid var(--border-hairline)" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 34, height: 34, borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-3)", color: k.tint, flex: "0 0 auto" }}><Ico n={k.icon} s={18} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{src.name}</span>
<StatusDot status={src.status === "warn" ? "warn" : "ok"} />
</div>
<div style={{ marginTop: 4, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-disabled)" }}>{src.detail}</div>
</div>
<Tooltip label="Scan source"><IconButton size="sm" title="Scan"><Ico n="RefreshCw" s={15} /></IconButton></Tooltip>
<Tooltip label="Settings"><IconButton size="sm" title="Settings"><Ico n="Settings2" s={15} /></IconButton></Tooltip>
</div>
{/* libraries */}
<div style={{ flex: 1 }}>
{src.libs.map((lib, i) => {
const s = LIB_STATUS[lib.status];
return (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 11, padding: "11px 15px", borderTop: i ? "1px solid var(--border-hairline)" : "none" }}>
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", width: 28, height: 28, borderRadius: "var(--radius-xs)", background: "var(--ctv-bg-sunken)", color: "var(--text-secondary)", flex: "0 0 auto" }}><Ico n={MEDIA_ICON[lib.media] || "Folder"} s={14} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>{lib.name}</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>{lib.media} · <span style={mono}>{lib.items.toLocaleString()}</span> items</div>
</div>
{lib.status === "scan"
? <div style={{ width: 118 }}><ProgressBar value={lib.pct} showLabel /></div>
: <Badge tone={s.tone} dot>{s.label}</Badge>}
</div>
);
})}
</div>
{/* footer */}
<div style={{ display: "flex", alignItems: "center", gap: 8, padding: "9px 15px", borderTop: "1px solid var(--border-hairline)", background: "var(--ctv-bg-sunken)" }}>
<Ico n="Clock" s={13} color="var(--text-faint)" />
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>
{scanning ? "Scanning now" : `Last scan ${src.lastScan}`}
</span>
<span style={{ flex: 1 }} />
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}><span style={mono}>{totalItems.toLocaleString()}</span> items · {src.libs.length} librar{src.libs.length === 1 ? "y" : "ies"}</span>
</div>
</div>
);
}
function AddSourceCard() {
const opts = [["HardDrive", "Local folder"], ["Server", "Plex"], ["Server", "Jellyfin"], ["Server", "Emby"]];
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12, padding: 16, background: "var(--surface-card)", border: "1px dashed var(--border-control)", borderRadius: "var(--radius-md)", alignItems: "flex-start", justifyContent: "center" }}>
<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)" }}><Ico n="Plus" s={18} /></span>
<div>
<div style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Connect a media source</div>
<div style={{ marginTop: 4, font: "var(--text-xs)/1.4 var(--font-sans)", color: "var(--text-secondary)" }}>Point ChicoryTV at a local folder or a Plex / Jellyfin / Emby server.</div>
</div>
<div style={{ display: "flex", gap: 7, flexWrap: "wrap" }}>
{opts.map(([ic, label], i) => (
<span key={i} style={{ display: "inline-flex", alignItems: "center", gap: 6, padding: "6px 10px", borderRadius: "var(--radius-sm)", background: "var(--ctv-surface-2)", border: "1px solid var(--border-hairline)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<Ico n={ic} s={13} />{label}
</span>
))}
</div>
</div>
);
}
function Libraries() {
const totalItems = D.sources.reduce((a, s) => a + s.libs.reduce((b, l) => b + l.items, 0), 0);
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 20px", borderBottom: "1px solid var(--border-hairline)", flex: "0 0 auto" }}>
<Button variant="secondary" startIcon={<Ico n="RefreshCw" s={15} />}>Scan All</Button>
<span style={{ flex: 1 }} />
<span style={{ font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-secondary)" }}>
<span style={mono}>{D.sources.length}</span> sources · <span style={mono}>{totalItems.toLocaleString()}</span> items
</span>
<Button variant="primary" startIcon={<Ico n="Plus" s={15} />}>Add Source</Button>
</div>
<div style={{ flex: 1, overflow: "auto", padding: 20 }}>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(360px, 1fr))", gap: 16, alignItems: "start" }}>
{D.sources.map((s, i) => <SourceCard key={i} src={s} />)}
<AddSourceCard />
</div>
</div>
</div>
);
}
window.CTVLibraries = Libraries;
})();
@@ -0,0 +1,144 @@
// App shell: left sidebar + compact top bar. Composes DS NavItem/NavSection.
(function () {
const NS = window.ChicoryTVDesignSystem_eb3b61;
const { NavItem, NavSection, Input, IconButton, StatusDot, Button } = NS;
const Ico = window.Ico;
const NAV = [
{ id: "dashboard", label: "Dashboard", icon: "LayoutDashboard" },
{ id: "channels", label: "Channels", icon: "Tv" },
{ id: "builder", label: "New Channel", icon: "Plus" },
{ id: "guide", label: "Guide", icon: "LayoutGrid" },
{ id: "schedules", label: "Schedules", icon: "CalendarClock" },
{ id: "playouts", label: "Playouts", icon: "ListVideo", badge: 3 },
];
const NAV_MEDIA = [
{ id: "collections", label: "Collections", icon: "FolderTree" },
{ id: "libraries", label: "Libraries", icon: "Library" },
];
const NAV_SYS = [
{ id: "settings", label: "Settings", icon: "Settings" },
];
function Sidebar({ view, setView }) {
return (
<aside style={{
width: "var(--sidebar-w)", flex: "0 0 auto", height: "100%",
background: "var(--surface-card)", borderRight: "1px solid var(--border-hairline)",
display: "flex", flexDirection: "column",
}}>
<div style={{ display: "flex", alignItems: "center", gap: 10, height: "var(--topbar-h)", padding: "0 14px", borderBottom: "1px solid var(--border-hairline)" }}>
<img src="../../assets/chicory-mark.svg" width="24" height="24" alt="" />
<span style={{ font: "var(--weight-semibold) 16px/1 var(--font-sans)", letterSpacing: "-0.3px", color: "var(--text-primary)" }}>
Chicory<span style={{ color: "var(--ctv-accent)" }}>TV</span>
</span>
</div>
<nav style={{ flex: 1, overflow: "auto", padding: "8px 8px 16px" }}>
{NAV.map((n) => (
<NavItem key={n.id} icon={<Ico n={n.icon} />} label={n.label} active={view === n.id}
badge={n.badge} badgeTone="warn" onClick={() => setView(n.id)} />
))}
<NavSection>Media</NavSection>
{NAV_MEDIA.map((n) => (
<NavItem key={n.id} icon={<Ico n={n.icon} />} label={n.label} active={view === n.id} onClick={() => setView(n.id)} />
))}
<NavSection>System</NavSection>
{NAV_SYS.map((n) => (
<NavItem key={n.id} icon={<Ico n={n.icon} />} label={n.label} active={view === n.id} onClick={() => setView(n.id)} />
))}
</nav>
<div style={{ padding: "12px 14px", borderTop: "1px solid var(--border-hairline)", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>ChicoryTV</span>
<span style={{ font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-secondary)" }}>v26.4.0</span>
</div>
<StatusDot status="ok" label="Healthy" />
</div>
</aside>
);
}
function TopBar({ title, actions }) {
return (
<header style={{
display: "flex", alignItems: "center", gap: 16, height: "var(--topbar-h)", flex: "0 0 auto",
padding: "0 20px", background: "var(--surface-app)", borderBottom: "1px solid var(--border-hairline)",
}}>
<h1 style={{ margin: 0, width: 150, flex: "0 0 auto", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", font: "var(--weight-semibold) var(--text-lg)/1 var(--font-sans)", letterSpacing: "-0.01em", color: "var(--text-primary)" }}>{title}</h1>
<div style={{ width: 300 }}>
<Input size="sm" leadingIcon={<Ico n="Search" s={15} />} placeholder="Search channels, schedules…" />
</div>
<div style={{ flex: 1 }} />
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<ConnectMenu />
<span style={{ width: 1, height: 18, background: "var(--border-hairline)", margin: "0 4px" }} />
<IconButton title="Documentation"><Ico n="CircleHelp" s={17} /></IconButton>
<IconButton title="Notifications"><Ico n="Bell" s={17} /></IconButton>
<div style={{ width: 28, height: 28, marginLeft: 4, borderRadius: "50%", background: "var(--ctv-accent-soft)", border: "1px solid rgba(91,124,250,.35)", display: "flex", alignItems: "center", justifyContent: "center", font: "var(--weight-semibold) 11px/1 var(--font-mono)", color: "var(--ctv-accent)" }}>TB</div>
</div>
{actions}
</header>
);
}
// GROUND-UP: replaces the two raw "M3U"/"XMLTV" endpoint links with a real
// "connect your player" surface — copyable URLs, a scan tile, and options.
function EndpointRow({ icon, label, url }) {
const [copied, setCopied] = React.useState(false);
const copy = () => {
try { navigator.clipboard && navigator.clipboard.writeText(url); } catch (e) {}
setCopied(true); setTimeout(() => setCopied(false), 1400);
};
return (
<div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)" }}>
<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={icon} s={15} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>{label}</div>
<div style={{ marginTop: 3, font: "var(--text-2xs)/1 var(--font-mono)", color: "var(--text-secondary)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{url}</div>
</div>
<button type="button" onClick={copy} title="Copy" style={{ display: "inline-flex", alignItems: "center", gap: 5, padding: "5px 8px", flex: "0 0 auto", border: "1px solid var(--border-control)", borderRadius: "var(--radius-xs)", background: copied ? "var(--ctv-ok-soft)" : "var(--ctv-surface-2)", color: copied ? "var(--status-ok)" : "var(--text-secondary)", cursor: "pointer", font: "var(--weight-medium) var(--text-2xs)/1 var(--font-sans)" }}>
<Ico n={copied ? "Check" : "Copy"} s={12} />{copied ? "Copied" : "Copy"}
</button>
</div>
);
}
function ConnectMenu() {
const [open, setOpen] = React.useState(false);
const [enabledOnly, setEnabledOnly] = React.useState(true);
const q = enabledOnly ? "?enabled=1" : "";
return (
<div style={{ position: "relative" }}>
<button type="button" onClick={() => setOpen((o) => !o)}
style={{ display: "inline-flex", alignItems: "center", gap: 7, height: 30, padding: "0 11px", borderRadius: "var(--radius-sm)", border: "1px solid var(--border-control)", background: open ? "var(--ctv-surface-3)" : "var(--ctv-surface-2)", color: "var(--text-primary)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)" }}>
<Ico n="Cast" s={15} color="var(--ctv-accent)" />Connect
<Ico n="ChevronDown" s={13} color="var(--text-disabled)" />
</button>
{open && (
<React.Fragment>
<div onClick={() => setOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 40 }} />
<div style={{ position: "absolute", top: "calc(100% + 8px)", right: 0, zIndex: 41, width: 340, padding: 14, background: "var(--surface-raised)", border: "1px solid var(--border-control)", borderRadius: "var(--radius-md)", boxShadow: "var(--shadow-pop)" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
<span style={{ font: "var(--weight-semibold) var(--text-sm)/1 var(--font-sans)", color: "var(--text-primary)" }}>Connect a player</span>
<span style={{ flex: 1 }} />
<span style={{ font: "var(--text-2xs)/1 var(--font-sans)", color: "var(--text-disabled)" }}>128 channels</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 12 }}>
<EndpointRow icon="ListVideo" label="M3U playlist" url={`chicory.tv/iptv/channels.m3u${q}`} />
<EndpointRow icon="CalendarClock" label="XMLTV guide" url={`chicory.tv/iptv/xmltv.xml${q}`} />
</div>
<label style={{ display: "flex", alignItems: "center", gap: 9, padding: "9px 11px", borderRadius: "var(--radius-sm)", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-hairline)", cursor: "pointer" }}>
<input type="checkbox" checked={enabledOnly} onChange={(e) => setEnabledOnly(e.target.checked)} style={{ accentColor: "var(--ctv-accent)" }} />
<span style={{ flex: 1, font: "var(--text-xs)/1 var(--font-sans)", color: "var(--text-primary)" }}>Only enabled channels</span>
</label>
<div style={{ marginTop: 11, display: "flex", alignItems: "center", gap: 7, font: "var(--text-2xs)/1.4 var(--font-sans)", color: "var(--text-disabled)" }}>
<Ico n="Info" s={13} />Works with any IPTV player — Jellyfin, TiviMate, VLC, Plex.
</div>
</div>
</React.Fragment>
)}
</div>
);
}
window.CTVShell = { Sidebar, TopBar };
})();
@@ -0,0 +1,142 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChicoryTV Admin</title>
<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">
<script src="./ds-base.js"></script>
<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>
// Apply saved preview theme before paint. Primary (warm) = no attribute.
try {
var t = localStorage.getItem("ctv-theme");
if (t === "chicory") t = "cool"; // migrate old default name
if (t === "cool" || t === "dual") document.documentElement.dataset.theme = t;
} catch (e) {}
</script>
<style>
html, body { margin: 0; height: 100%; background: var(--surface-app); color: var(--text-primary); font-family: var(--font-sans); }
#root { height: 100vh; }
* { box-sizing: border-box; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--ctv-surface-3); border-radius: 6px; border: 2px solid var(--surface-app); }
::-webkit-scrollbar-track { background: transparent; }
/* Overlay scroll regions (custom fading indicator) hide the native bar so
content never reflows when a scrollbar appears/disappears. */
.ctv-hide-sb::-webkit-scrollbar { width: 0; height: 0; }
/* Motion primitives — subtle, iOS/macOS-style. Quick, ease-out, small. */
@keyframes ctv-pop { from { transform: scale(0.4); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes ctv-fade-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.ctv-lift { transition: transform 240ms cubic-bezier(0.16,1,0.3,1), box-shadow 240ms cubic-bezier(0.16,1,0.3,1); }
.ctv-lift:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.ctv-press { transition: transform 150ms cubic-bezier(0.16,1,0.3,1), background 220ms cubic-bezier(0.16,1,0.3,1), color 220ms cubic-bezier(0.16,1,0.3,1), border-color 220ms cubic-bezier(0.16,1,0.3,1); }
.ctv-press:active { transform: scale(0.96); }
@media (prefers-reduced-motion: reduce) {
.ctv-lift, .ctv-press { transition: none; }
.ctv-lift:hover, .ctv-press:active { transform: none; }
* { scroll-behavior: auto !important; }
}
</style>
</head>
<body>
<div id="root"></div>
<!-- Theme preview switcher — a design-system demo affordance, not product chrome -->
<div id="theme-switch" style="position:fixed;left:16px;bottom:14px;z-index:999;display:flex;align-items:center;gap:8px;padding:7px 9px;background:var(--surface-raised);border:1px solid var(--border-control);border-radius:var(--radius-pill);box-shadow:var(--shadow-pop);font-family:var(--font-sans)">
<span style="font:600 10px/1 var(--font-sans);letter-spacing:.06em;text-transform:uppercase;color:var(--text-disabled);padding-left:4px">Theme</span>
<button data-theme-btn="warm" title="Warm chicory (primary)" style="width:22px;height:22px;border-radius:50%;border:2px solid transparent;background:#E08A3C;cursor:pointer;padding:0"></button>
<button data-theme-btn="cool" title="Cool periwinkle" style="width:22px;height:22px;border-radius:50%;border:2px solid transparent;background:#5B7CFA;cursor:pointer;padding:0"></button>
<button data-theme-btn="dual" title="Dual accent" style="width:22px;height:22px;border-radius:50%;border:2px solid transparent;background:linear-gradient(135deg,#5B7CFA 0 50%,#F0663F 50% 100%);cursor:pointer;padding:0"></button>
</div>
<script>
(function () {
var current = document.documentElement.dataset.theme || "warm";
var btns = document.querySelectorAll("#theme-switch [data-theme-btn]");
function paint() {
current = document.documentElement.dataset.theme || "warm";
btns.forEach(function (b) {
b.style.borderColor = b.getAttribute("data-theme-btn") === current ? "var(--text-primary)" : "transparent";
});
}
btns.forEach(function (b) {
b.addEventListener("click", function () {
var t = b.getAttribute("data-theme-btn");
if (t === "warm") delete document.documentElement.dataset.theme;
else document.documentElement.dataset.theme = t;
try { localStorage.setItem("ctv-theme", t); } catch (e) {}
paint();
});
});
paint();
})();
</script>
<script src="data.js"></script>
<script type="text/babel" src="icons.jsx" data-presets="react"></script>
<script type="text/babel" src="Shell.jsx" data-presets="react"></script>
<script type="text/babel" src="Dashboard.jsx" data-presets="react"></script>
<script type="text/babel" src="Channels.jsx" data-presets="react"></script>
<script type="text/babel" src="ChannelBuilder.jsx" data-presets="react"></script>
<script type="text/babel" src="ScheduleEditor.jsx" data-presets="react"></script>
<script type="text/babel" src="Epg.jsx" data-presets="react"></script>
<script type="text/babel" src="Playouts.jsx" data-presets="react"></script>
<script type="text/babel" src="ScheduleLibrary.jsx" data-presets="react"></script>
<script type="text/babel" data-presets="react">
const { Sidebar, TopBar } = window.CTVShell;
const TITLES = {
dashboard: "Dashboard", channels: "Channels", builder: "New Channel", guide: "Guide", schedules: "Schedules",
playouts: "Playouts", collections: "Collections", libraries: "Libraries", settings: "Settings",
};
function Placeholder({ label }) {
const Ico = window.Ico;
return (
<div style={{ height: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 12, color: "var(--text-disabled)" }}>
<Ico n="Wrench" s={26} />
<span style={{ font: "var(--text-sm)/1 var(--font-sans)" }}>{label} — not part of this UI kit</span>
</div>
);
}
function App() {
const [view, setView] = React.useState("dashboard");
const screens = {
dashboard: window.CTVDashboard,
channels: window.CTVChannels,
builder: window.CTVChannelBuilder,
guide: window.CTVEpg,
schedules: window.CTVScheduleEditor,
playouts: window.CTVPlayouts,
libraries: window.CTVLibraries,
};
const Screen = screens[view];
return (
<div style={{ display: "flex", height: "100%" }}>
<Sidebar view={view} setView={setView} />
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", height: "100%" }}>
<TopBar title={TITLES[view]} />
<main style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
{Screen ? <Screen /> : <Placeholder label={TITLES[view]} />}
</main>
</div>
</div>
);
}
function mount() {
if (!window.ChicoryTVDesignSystem_eb3b61 || !window.CTVShell || !window.CTVDashboard ||
!window.CTVChannels || !window.CTVChannelBuilder || !window.CTVScheduleEditor || !window.CTVEpg ||
!window.CTVPlayouts || !window.CTVLibraries) {
return setTimeout(mount, 40);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
}
mount();
</script>
</body>
</html>
@@ -0,0 +1,130 @@
// Mock data for the ChicoryTV admin UI kit — vocabulary mirrors ErsatzTV
// (channels, streaming modes, FFmpeg profiles, playouts, schedules, libraries).
window.CTV_DATA = {
channels: [
{ num: "1.1", name: "Retro Cartoons", group: "Cartoons", lang: "English", mode: "HLS Segmenter", profile: "1080p H.264", live: true, enabled: true, epg: true },
{ num: "1.2", name: "Anime Block", group: "Cartoons", lang: "Japanese",mode: "HLS Segmenter", profile: "1080p H.264", live: false, enabled: true, epg: true },
{ num: "2.1", name: "News 24", group: "News", lang: "English", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true },
{ num: "3.1", name: "Late Night Movies",group: "Movies", lang: "English", mode: "MPEG-TS", profile: "1080p HEVC", live: false, enabled: true, epg: true },
{ num: "7.3", name: "Sci-Fi Marathon", group: "Movies", lang: "English", mode: "MPEG-TS", profile: "1080p H.264", live: false, enabled: true, epg: false },
{ num: "4.2", name: "Music Videos", group: "Music", lang: "English", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true },
{ num: "5.1", name: "Nature Docs", group: "Docs", lang: "Spanish", mode: "HLS Direct", profile: "—", live: false, enabled: true, epg: true },
{ num: "9.1", name: "Kids Block", group: "Kids", lang: "French", mode: "HLS Segmenter", profile: "720p H.264", live: true, enabled: true, epg: true },
{ num: "12.1", name: "Test Pattern", group: "System", lang: "English", mode: "MPEG-TS", profile: "480p H.264", live: false, enabled: false, epg: false },
],
playoutItems: [
{ start: "20:00:00", finish: "20:24:00", title: "Looney Tunes — Rabbit Fire", dur: "00:24:00", kind: "content" },
{ start: "20:24:00", finish: "20:24:30", title: "Station ID — Retro Bumper", dur: "00:00:30", kind: "filler" },
{ start: "20:24:30", finish: "20:48:30", title: "Tom & Jerry — The Cat Concerto", dur: "00:24:00", kind: "content" },
{ start: "20:48:30", finish: "20:52:30", title: "Retro Ad Break (4 spots)", dur: "00:04:00", kind: "filler" },
{ start: "20:52:30", finish: "21:16:30", title: "Popeye — Sindbad the Sailor", dur: "00:24:00", kind: "content" },
{ start: "21:16:30", finish: "21:17:00", title: "Station ID — Retro Bumper", dur: "00:00:30", kind: "filler" },
{ start: "21:17:00", finish: "21:47:00", title: "The Flintstones — The Big Bank", dur: "00:30:00", kind: "content" },
],
schedules: [
{ name: "Prime Time Cartoons", items: 14, mode: "Multiple", pad: "30 min" },
{ name: "Overnight Movies", items: 6, mode: "One", pad: "None" },
{ name: "Music Rotation", items: 22, mode: "Shuffle", pad: "5 min" },
{ name: "Weekend Docs", items: 9, mode: "Ordered", pad: "15 min" },
],
// Media sources — GROUND-UP: libraries grouped under their source connection.
sources: [
{ kind: "Local", name: "Local media", detail: "/media/library", status: "connected", lastScan: "2h ago",
libs: [ { name: "Cartoons", media: "Other Videos", items: 1284, status: "ok" }, { name: "Music Videos", media: "Music Videos", items: 2960, status: "ok" } ] },
{ kind: "Plex", name: "Plex — Living Room", detail: "plex.local:32400", status: "connected", lastScan: "scanning",
libs: [ { name: "Movies", media: "Movies", items: 642, status: "scan", pct: 62 } ] },
{ kind: "Jellyfin", name: "Jellyfin", detail: "jelly.local:8096", status: "connected", lastScan: "5h ago",
libs: [ { name: "TV Shows", media: "Shows", items: 318, status: "ok" } ] },
{ kind: "Emby", name: "Emby", detail: "emby.local:8096", status: "warn", lastScan: "1d ago",
libs: [ { name: "Documentaries", media: "Movies", items: 210, status: "warn" } ] },
],
libraries: [
{ kind: "Local", name: "Cartoons", media: "Other Videos", items: 1284, status: "ok", pct: 100 },
{ kind: "Plex", name: "Movies", media: "Movies", items: 642, status: "scan", pct: 62 },
{ kind: "Jellyfin", name: "TV Shows", media: "Shows", items: 318, status: "ok", pct: 100 },
{ kind: "Local", name: "Music Videos", media: "Music Videos", items: 2960, status: "ok", pct: 100 },
{ kind: "Emby", name: "Documentaries",media: "Movies", items: 210, status: "warn", pct: 100 },
],
activity: [
{ icon: "RefreshCw", tone: "accent", text: "Playout rebuilt for 1.1 Retro Cartoons", time: "2m ago" },
{ icon: "Radio", tone: "ok", text: "Channel 4.2 Music Videos went on air", time: "9m ago" },
{ icon: "Library", tone: "accent", text: "Scan started · Plex Movies", time: "14m ago" },
{ icon: "TriangleAlert", tone: "warn", text: "FFmpeg profile 720p H.264 not normalizing audio", time: "28m ago" },
{ icon: "Check", tone: "ok", text: "XMLTV guide regenerated (128 channels)", time: "41m ago" },
],
// Health checks — real ErsatzTV check titles. status: pass|info|warning|fail
health: [
{ title: "FFmpeg Version", status: "pass", detail: "6.1.1 detected" },
{ title: "Hardware Acceleration", status: "pass", detail: "VAAPI available" },
{ title: "FFmpeg Capabilities", status: "info", detail: "QSV not configured" },
{ title: "Movie Metadata", status: "warning", detail: "12 movies missing metadata" },
{ title: "Zero Duration", status: "warning", detail: "3 items report 00:00:00" },
{ title: "File Not Found", status: "fail", detail: "8 files missing on disk" },
{ title: "Episode Metadata", status: "pass", detail: "All episodes tagged" },
{ title: "VAAPI Driver", status: "pass", detail: "iHD driver loaded" },
],
releaseNotes: {
version: "v26.4.0",
date: "Jun 28, 2026",
items: [
"New: drag-to-reorder schedule items in the redesigned editor.",
"New: dark EPG grid with jump-to-now.",
"Fixed: HLS Segmenter audio drift on some MPEG-TS sources.",
"Changed: channel logos now cache for 24h.",
],
},
// Schedule editor — items (left lineup) with full detail (inspector)
scheduleItems: [
{ id: 1, startType: "Fixed", start: "20:00", collection: "Prime Time Cartoons", collType: "Multi Collection", order: "Shuffle", mode: "Multiple", count: 3, guide: "Normal", durEst: "~1h 12m", pre: true, mid: true, post: false },
{ id: 2, startType: "Dynamic", start: "—", collection: "Retro Station IDs", collType: "Collection", order: "Random", mode: "One", count: 1, guide: "Filler", durEst: "~0m 30s", pre: false, mid: false, post: false },
{ id: 3, startType: "Fixed", start: "20:30", collection: "Classic Shorts", collType: "Smart Collection", order: "Shuffle In Order", mode: "Multiple", count: 2, guide: "Normal", durEst: "~24m", pre: false, mid: true, post: false },
{ id: 4, startType: "Dynamic", start: "—", collection: "Retro Ad Break", collType: "Playlist", order: "Chronological", mode: "One", count: 1, guide: "Filler", durEst: "~4m", pre: false, mid: false, post: false },
{ id: 5, startType: "Fixed", start: "21:00", collection: "Feature Presentation",collType: "Television Show", order: "Season/Episode", mode: "One", count: 1, guide: "Normal", durEst: "~1h 36m", pre: true, mid: false, post: true },
],
// EPG grid — channels (subset) × programmes. offset/span in 30-min slots from grid start.
epgStart: "20:00",
epgSlots: ["20:00", "20:30", "21:00", "21:30", "22:00", "22:30", "23:00"],
epg: [
{ num: "1.1", name: "Retro Cartoons", programmes: [
{ title: "Looney Tunes", sub: "Rabbit Fire", cat: "Animation", offset: 0, span: 1 },
{ title: "Tom & Jerry", sub: "The Cat Concerto", cat: "Animation", offset: 1, span: 1, live: true },
{ title: "Popeye", sub: "Sindbad the Sailor", cat: "Animation", offset: 2, span: 1 },
{ title: "The Flintstones", sub: "The Big Bank", cat: "Animation", offset: 3, span: 2 },
{ title: "Bumper", sub: "Station ID", cat: "Filler", offset: 5, span: 1, filler: true },
]},
{ num: "2.1", name: "News 24", programmes: [
{ title: "World Tonight", sub: "Evening bulletin", cat: "News", offset: 0, span: 2, live: true },
{ title: "Market Watch", sub: "Close of day", cat: "News", offset: 2, span: 1 },
{ title: "Weather", sub: "7-day outlook", cat: "News", offset: 3, span: 1, filler: true },
{ title: "The Interview", sub: "Long form", cat: "Talk", offset: 4, span: 2 },
]},
{ num: "3.1", name: "Late Night Movies", programmes: [
{ title: "Blade Runner", sub: "1982 · Sci-Fi", cat: "Movie", offset: 0, span: 4, live: true },
{ title: "Alien", sub: "1979 · Horror", cat: "Movie", offset: 4, span: 3 },
]},
{ num: "4.2", name: "Music Videos", programmes: [
{ title: "80s Block", sub: "Synthpop hour", cat: "Music", offset: 0, span: 2, live: true },
{ title: "Ad Break", sub: "", cat: "Filler", offset: 2, span: 1, filler: true },
{ title: "90s Block", sub: "Alt rotation", cat: "Music", offset: 3, span: 2 },
{ title: "Chart Countdown", sub: "Top 20", cat: "Music", offset: 5, span: 2 },
]},
{ num: "5.1", name: "Nature Docs", programmes: [
{ title: "Blue Planet", sub: "The Deep", cat: "Documentary", offset: 0, span: 2, live: true },
{ title: "Planet Earth", sub: "Mountains", cat: "Documentary", offset: 2, span: 2 },
{ title: "Bumper", sub: "Station ID", cat: "Filler", offset: 4, span: 1, filler: true },
{ title: "Frozen Planet", sub: "To the Ends", cat: "Documentary", offset: 5, span: 2 },
]},
{ num: "9.1", name: "Kids Block", programmes: [
{ title: "Sesame Street", sub: "Episode 4102", cat: "Kids", offset: 0, span: 2, live: true },
{ title: "Bluey", sub: "Keepy Uppy", cat: "Kids", offset: 2, span: 1 },
{ title: "Ad Break", sub: "", cat: "Filler", offset: 3, span: 1, filler: true },
{ title: "Peppa Pig", sub: "Muddy Puddles", cat: "Kids", offset: 4, span: 3 },
]},
],
};
@@ -0,0 +1,29 @@
// Loads this design system into the template. In a consuming project, point
// base at the bound DS folder relative to this file (e.g. '_ds/<folder>' at
// the project root, '../_ds/<folder>' one level down) — one line to edit.
(() => {
const base = '../..';
for (const p of ["tokens/fonts.css","tokens/colors.css","tokens/typography.css","tokens/spacing.css","tokens/radius-elevation-motion.css","tokens/themes.css","styles.css"]) {
const l = document.createElement('link');
l.rel = 'stylesheet'; l.href = base + '/' + p;
document.head.appendChild(l);
}
// The component bundle MUST be defined before the type="text/babel" screen
// scripts are transpiled/executed: each screen IIFE destructures
// window.<Namespace> at definition time and would throw if it were absent.
// appendChild loads async and loses that race, so while this file is being
// parsed synchronously in <head> we insert a parser-blocking <script> via
// document.write — guaranteeing the bundle loads first. (The bundle
// evaluates without referencing React, so sitting ahead of the React tags
// is harmless.) Late/dynamic inclusion falls back to appendChild.
const src = base + '/_ds_bundle.js';
const onerror = 'ds-base.js: failed to load ' + src + ' — if this is a consuming project, point the base line in ds-base.js at the bound _ds/<folder> tree relative to this page (e.g. _ds/<folder> at the project root, ../_ds/<folder> one level down); in a fresh design system this can just mean the bundle is not compiled yet';
if (document.readyState === 'loading') {
document.write('<script src="' + src + '" onerror="console.error(&quot;' + onerror.replace(/"/g, '&quot;') + '&quot;)"><\/script>');
} else {
const s = document.createElement('script');
s.src = src;
s.onerror = () => console.error(onerror);
document.head.appendChild(s);
}
})();
@@ -0,0 +1,18 @@
// Shared Lucide icon helper for the ChicoryTV UI kit.
// Renders a stroke icon by name into a span. Requires window.lucide (UMD).
function Ico({ n, s = 16, color, style }) {
const ref = React.useRef(null);
React.useEffect(() => {
if (!ref.current || !window.lucide) return;
ref.current.innerHTML = "";
const def = window.lucide.icons[n];
if (!def) return;
const el = window.lucide.createElement(def);
el.setAttribute("width", s);
el.setAttribute("height", s);
ref.current.appendChild(el);
});
return <span ref={ref} style={{ display: "inline-flex", color: color || "currentColor", ...style }} />;
}
window.Ico = Ico;

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