Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions apps/web/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import {
import { customBadge } from '@/flags';

export const metadata: Metadata = {
title: 'UVAI — YouTube URL to Studio',
title: 'UVAI — YouTube videos to bound tasks',
description:
'Paste a YouTube URL to open a hashed Video Pack in Studio. Transcript quality varies by source.',
'Bind YouTube videos to source-linked tasks, expected outputs, and proposed checks in Studio. Transcript quality varies by source.',
alternates: { canonical: '/' },
};

Expand Down Expand Up @@ -49,10 +49,10 @@ export default async function HomePage() {
<div className="mx-auto max-w-6xl">
<HomeTemplateShell context={
<div>
<p className="uvai-label">Source → context → next step</p>
<h2 className="mt-4 text-xl">A workspace for what comes next.</h2>
<p className="uvai-label">Video → tasks → checks</p>
<h2 className="mt-4 text-xl">Every action has a source.</h2>
<p className="mt-3 text-sm leading-relaxed" style={{ color: 'var(--uvai-ink-soft)' }}>
Start with the source. Review the evidence before deciding what to build.
Keep the spoken action, expected output, and acceptance check together.
</p>
<ol className="mt-8 space-y-8">
<li className="home-workflow-step">
Expand All @@ -63,12 +63,12 @@ export default async function HomePage() {
<li className="home-workflow-step">
<span className="home-step-number" aria-hidden="true">02</span>
<div><h3 className="text-base font-medium">Inspect the Video Pack</h3>
<p className="mt-2 text-sm" style={{ color: 'var(--uvai-ink-soft)' }}>Studio organizes the source into a hashed Video Pack with events, tools, and proposed work.</p></div>
<p className="mt-2 text-sm" style={{ color: 'var(--uvai-ink-soft)' }}>Studio organizes the source into a hashed Video Pack. The Tasks view connects requirements to source timestamps and acceptance criteria.</p></div>
</li>
<li className="home-workflow-step">
<span className="home-step-number" aria-hidden="true">03</span>
<div><h3 className="text-base font-medium">Review your next move</h3>
<p className="mt-2 text-sm" style={{ color: 'var(--uvai-ink-soft)' }}>Ask questions and inspect build rails. Proposed work is not a verified deployment.</p></div>
<div><h3 className="text-base font-medium">Inspect the checks</h3>
<p className="mt-2 text-sm" style={{ color: 'var(--uvai-ink-soft)' }}>Review each expected output and export the bound tasks. Unrun checks stay unverified; proposed work is not a verified deployment.</p></div>
</li>
</ol>
<div className="mt-8 border-t pt-5 text-xs leading-relaxed" style={{ borderColor: 'var(--uvai-border)', color: 'var(--uvai-ink-soft)' }}>
Expand All @@ -89,11 +89,12 @@ export default async function HomePage() {
)}

<h1 id="home-heading" className="mt-5 max-w-2xl text-3xl sm:text-4xl" style={{ color: 'var(--uvai-ink)' }}>
Your videos know what to do next.
Turn YouTube into bound tasks.
</h1>
<p className="mt-4 max-w-xl text-sm leading-relaxed" style={{ color: 'var(--uvai-ink-soft)' }}>
Paste a YouTube URL. UVAI pulls the events, actions, and decisions out of it,
prepares proposed work and build rails for you to review in Studio.
Paste a YouTube URL. UVAI connects source-linked actions, expected outputs,
and proposed checks in one workspace. Follow the timestamp, review the task,
and take the next step toward your build.
</p>
<p className="mt-3 text-sm" style={{ color: 'var(--uvai-ink-faint)' }}>
Transcript quality varies by source. No guaranteed production outcome.
Expand Down
11 changes: 6 additions & 5 deletions apps/web/src/components/home/HomePasteForm.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { FormEvent, useState } from 'react';
import { ArrowUp, Link2 } from 'lucide-react';
import { ArrowRight, Link2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { submitHomePaste } from '@/lib/studio-handoff';

Expand Down Expand Up @@ -46,10 +46,11 @@ export default function HomePasteForm() {
/>
<button
type="submit"
aria-label="Run in Studio"
className="template-send"
aria-label="Build from video in Studio"
className="template-send home-build-button"
>
<ArrowUp size={20} aria-hidden="true" />
<span>Build from video</span>
<ArrowRight size={18} aria-hidden="true" />
</button>
</div>
{error ? (
Expand All @@ -58,7 +59,7 @@ export default function HomePasteForm() {
</p>
) : (
<p id="home-youtube-url-help" className="mt-2 text-left text-xs" style={{ color: 'var(--uvai-ink-faint)' }}>
Opens Studio and starts the Video Pack handoff. Transcript quality varies by source.
Opens Studio to extract a Video Pack and inspect source-linked tasks. Transcript quality varies by source.
</p>
)}
</form>
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/home/home-template.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
.home-source-composer { margin-top: 28px; }
.home-source-composer label { font-size: 10px; text-align: center; }
.home-source-composer form > p { text-align: center; line-height: 1.5; }
.template-source-input .home-build-button { width: auto; min-width: fit-content; padding: 0 14px; gap: 8px; border-radius: 12px; font-size: 12px; font-weight: 600; white-space: nowrap; }
.home-build-button:focus-visible { outline: 2px solid var(--uvai-accent); outline-offset: 4px; }
@media (max-width: 480px) {
.template-source-input:has(.home-build-button) { flex-wrap: wrap; padding: 10px; }
.template-source-input .home-build-button { width: 100%; min-height: 40px; margin-top: 4px; }
}
@media (max-width: 1023px) {
.home-template-shell { grid-template-columns: minmax(0, 1fr); min-height: auto; }
.home-template-primary { padding: 32px 4px; }
Expand Down
203 changes: 203 additions & 0 deletions apps/web/src/components/studio/StudioBoundTasks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
'use client';

import { useEffect, useMemo, useState } from 'react';
import type { Video } from '@/store/dashboard-types';
import { hashReviewContent, inspectGroundedSpec, type ReviewInspection } from '@/lib/grounded-build-spec';
import { projectStudioBoundTasks, type StudioBoundTask } from '@/lib/studio-bound-tasks';

type Pack = NonNullable<Video['videoPack']>['pack'];

function timestamp(seconds: number): string {
const whole = Math.floor(seconds);
const hours = Math.floor(whole / 3600);
const minutes = Math.floor((whole % 3600) / 60);
const tail = String(whole % 60).padStart(2, '0');
return hours ? `${hours}:${String(minutes).padStart(2, '0')}:${tail}` : `${minutes}:${tail}`;
}

/** A source-bound planning view. No action or check is executed by this panel. */
export default function StudioBoundTasks({ pack }: { pack?: Pack }) {
const inspection = useMemo<ReviewInspection>(() => (
pack ? inspectGroundedSpec(pack) : { status: 'unavailable', issues: [] }
), [pack]);
const view = useMemo(() => projectStudioBoundTasks(inspection), [inspection]);
const [digest, setDigest] = useState<{ inspection: ReviewInspection; status: 'valid' | 'mismatch' | 'unavailable' } | null>(null);
const [exportError, setExportError] = useState('');

useEffect(() => {
if (inspection.status !== 'available') return;
let current = true;
void hashReviewContent(inspection.canonical).then((hash) => {
if (current) setDigest({ inspection, status: hash === inspection.contentHash ? 'valid' : 'mismatch' });
}).catch(() => {
if (current) setDigest({ inspection, status: 'unavailable' });
});
return () => { current = false; };
}, [inspection]);
const digestStatus = digest?.inspection === inspection ? digest.status : 'checking';

function exportTasks() {
if (view.status !== 'available' || digestStatus !== 'valid' || !view.tasks.length) return;
let url: string | undefined;
try {
const blob = new Blob([JSON.stringify({
version: 'uvai-bound-tasks/v1',
kind: 'proposed-work',
...view,
}, null, 2)], { type: 'application/json' });
url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `uvai-tasks-${view.source.videoId}.json`;
document.body.appendChild(link);
link.click();
link.remove();
setExportError('');
} catch {
setExportError('The task file could not be downloaded. Please try again.');
} finally {
if (url) {
const downloadUrl = url;
// Give the browser time to consume the download before releasing it.
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
}
}
}

if (view.status !== 'available') {
return (
<section aria-label="Bound tasks" data-testid="studio-bound-tasks" className="uvai-card p-6">
<h2 className="text-lg font-medium">Every task starts with its source.</h2>
<p className="mt-3 text-sm leading-relaxed text-slate-600">
{view.status === 'invalid'
? 'The task bindings could not be validated. Review the source issues before continuing.'
: view.status === 'source-unavailable'
? 'The source is unavailable. A usable Video Pack is needed to bind tasks and checks.'
: 'Run a YouTube video to inspect source-linked tasks, expected outputs, and proposed checks here.'}
</p>
<p className="mt-3 text-xs text-slate-500">No tasks have been executed or independently checked by this view.</p>
{view.issues.length > 0 && (
<ul className="mt-4 space-y-2 text-xs text-amber-800" aria-label="Source issues">
{view.issues.map((issue, index) => <li key={`${issue.code}-${index}`}>{issue.message}</li>)}
</ul>
)}
</section>
);
}

if (digestStatus !== 'valid') {
return (
<section aria-label="Bound tasks" data-testid="studio-bound-tasks" className="uvai-card p-6">
<h2 className="text-lg font-medium">Checking task bindings</h2>
<p role="status" className="mt-3 text-sm text-slate-600">
{digestStatus === 'checking' ? 'Checking exact source and specification content…'
: digestStatus === 'mismatch' ? 'The content digest does not match. Task display and export are held.'
: 'Content verification is unavailable. Task display and export are held.'}
</p>
</section>
);
}
const blockingIssues = view.issues.filter((issue) => issue.severity === 'blocking');
return (
<section aria-label="Bound tasks" data-testid="studio-bound-tasks" className="space-y-4">
<header className="space-y-3 px-1">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-xl font-medium">From source to next action.</h2>
<p className="mt-1 text-xs text-slate-500">{view.tasks.length} {view.tasks.length === 1 ? 'task' : 'tasks'} bound to this Video Pack</p>
</div>
<button type="button" onClick={exportTasks} disabled={!view.tasks.length}
className="uvai-btn uvai-btn-secondary !px-3 !py-2 !text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-700">
Export tasks
</button>
</div>
<p className="text-xs leading-relaxed text-slate-600">
Source-linked proposals. Actions have not run, outputs have not been generated, and independent checks have not been performed by this view.
</p>
{exportError && <p role="alert" className="text-xs text-red-700">{exportError}</p>}
<details className="text-xs text-slate-500">
<summary className="cursor-pointer py-1">Source identity</summary>
<dl className="mt-2 space-y-2 break-all font-mono text-[11px]">
<div><dt className="font-semibold">Video Pack</dt><dd>{view.source.packId}</dd></div>
<div><dt className="font-semibold">Source hash</dt><dd>{view.source.sourceHash}</dd></div>
<div><dt className="font-semibold">Specification hash</dt><dd>{view.contentHash}</dd></div>
</dl>
</details>
{view.issues.some((issue) => issue.severity === 'warning') && (
<details className="text-xs text-slate-600">
<summary className="cursor-pointer py-1">Source limitations</summary>
<ul className="mt-2 list-disc space-y-1 pl-4">
{view.issues.filter((issue) => issue.severity === 'warning').map((issue, index) => (
<li key={`${issue.code}-${index}`}>{issue.message}</li>
))}
</ul>
</details>
)}
</header>
{blockingIssues.length > 0 && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-xs text-amber-900">
<p className="font-semibold">Resolve before execution</p>
<ul className="mt-2 list-disc space-y-1 pl-4">
{blockingIssues.map((issue, index) => <li key={`${issue.code}-${index}`}>{issue.message}</li>)}
</ul>
</div>
)}
{view.tasks.length === 0 ? <p className="uvai-card text-sm text-slate-600">This specification contains no tasks.</p> : (
<ol className="space-y-4" aria-label="Source-linked tasks">
{view.tasks.map((task, index) => <TaskRow key={task.id} task={task} index={index} />)}
</ol>
)}
</section>
);
}

function TaskRow({ task, index }: { task: StudioBoundTask; index: number }) {
return (
<li className="uvai-card !p-0 overflow-hidden" data-testid="studio-bound-task">
<div className="border-b border-slate-100 p-4">
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px]">
<span className="font-mono text-slate-500">{String(index + 1).padStart(2, '0')} · {task.id}</span>
<span className="capitalize text-slate-600">{task.classification} · {task.required ? 'Required' : 'Optional'}</span>
</div>
<h3 className="mt-2 !font-sans text-sm font-semibold">{task.title}</h3>
{task.detail && <p className="mt-2 text-xs leading-relaxed text-slate-600">{task.detail}</p>}
</div>
<div className="space-y-4 p-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Source</p>
{task.citations.length === 0 ? <p className="mt-2 text-xs text-amber-800">No direct source citation. This task needs review.</p> : (
<ul className="mt-2 space-y-3">
{task.citations.map((citation, index) => (
<li key={`${citation.kind}-${citation.index}-${index}`}>
<a href={citation.sourceUrl} target="_blank" rel="noopener noreferrer"
className="font-mono text-xs text-orange-800 underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-2">
{timestamp(citation.startSeconds)}–{timestamp(citation.endSeconds)} · {citation.kind === 'transcript' ? 'Spoken source' : 'Visual observation'} ↗
</a>
<blockquote className="mt-2 border-l-2 border-slate-200 pl-3 text-xs leading-relaxed text-slate-600">{citation.quote}</blockquote>
</li>
))}
</ul>
)}
</div>
<div>
<p className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Expected output & checks</p>
{task.checks.length === 0 ? <p className="mt-2 text-xs text-amber-800">No acceptance criteria supplied. Define a check before execution.</p> : (
<ul className="mt-2 space-y-3">
{task.checks.map((check) => (
<li key={check.id} className="rounded-lg bg-slate-50 p-3 text-xs">
<div className="flex justify-between gap-2"><span className="font-mono text-slate-500">{check.id}</span><span className="font-medium text-amber-800">Not run</span></div>
<dl className="mt-2 space-y-1 leading-relaxed text-slate-600">
<div><dt className="inline font-medium">Given </dt><dd className="inline">{check.given}</dd></div>
<div><dt className="inline font-medium">When </dt><dd className="inline">{check.when}</dd></div>
<div><dt className="inline font-medium">Then </dt><dd className="inline">{check.then}</dd></div>
</dl>
</li>
))}
</ul>
)}
</div>
<p className="border-t border-slate-100 pt-3 text-[11px] leading-relaxed text-slate-500">Action: not run · Output: not generated · Independent check: unverified</p>
</div>
</li>
);
}
9 changes: 6 additions & 3 deletions apps/web/src/components/studio/StudioIdeOutput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@ import type { Video } from '@/store/dashboard-types';
import type { ExtractedEvent } from '@/lib/types';
import { studioActionCard } from '@/lib/studio-pipeline-status';
import type { StudioStackCheckView } from '@/lib/official-templates';
import StudioBoundTasks from './StudioBoundTasks';

export type StudioReviewActionInput = {
tool: string;
status: string;
result?: string;
};

type OutputSection = 'events' | 'lingo' | 'tools' | 'intent' | 'signals' | 'spec' | 'transcript';
type OutputSection = 'tasks' | 'events' | 'lingo' | 'tools' | 'intent' | 'signals' | 'spec' | 'transcript';

const SECTIONS: Array<{ id: OutputSection; label: string }> = [
{ id: 'tasks', label: 'Tasks' },
{ id: 'events', label: 'Events' },
{ id: 'lingo', label: 'Lingo' },
{ id: 'tools', label: 'Tools' },
Expand Down Expand Up @@ -50,7 +52,7 @@ export default function StudioIdeOutput({
stackChecks?: StudioStackCheckView[];
reviewActions?: StudioReviewActionInput[];
}) {
const [active, setActive] = useState<OutputSection>('events');
const [active, setActive] = useState<OutputSection>('tasks');

const hasContent = Boolean(
video?.videoPack ||
Expand Down Expand Up @@ -100,7 +102,7 @@ export default function StudioIdeOutput({
</div>

<div className="min-h-0 flex-1 overflow-y-auto bg-neutral-50 p-4">
{!hasContent && active !== 'transcript' ? (
{!hasContent && active !== 'transcript' && active !== 'tasks' ? (
<div className="uvai-empty">
<p className="font-medium text-slate-600">
Run a video and the extracted actions land here
Expand All @@ -109,6 +111,7 @@ export default function StudioIdeOutput({
</div>
) : (
<>
{active === 'tasks' && <StudioBoundTasks key={video?.id ?? 'empty'} pack={pack} />}
{active === 'events' && (
<ul className="flex flex-col gap-3" data-testid="studio-ide-output-events">
{events.length === 0 ? (
Expand Down
Loading
Loading