Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
ce46634
feat(web): densify image editor workspace
rodrgds Sep 17, 2026
56bee74
feat(web): quieten media export and settings surfaces
rodrgds Sep 17, 2026
98c0f93
feat(web): densify preview and on-canvas tools
rodrgds Sep 17, 2026
ffb6e4c
feat(web): densify text transcript and motion panels
rodrgds Sep 17, 2026
d9ca117
feat(web): add editor-density workstation primitives
rodrgds Sep 17, 2026
c298526
feat(web): compress video editor shell chrome
rodrgds Sep 17, 2026
c02daf6
feat(web): densify video inspector panels
rodrgds Sep 17, 2026
816136b
feat(web): consolidate editor browsers into pickers
rodrgds Sep 17, 2026
2da9cef
merge(web): integrate editor-desktop-grade-a-primitives slice
rodrgds Sep 17, 2026
977d7f3
merge(web): integrate editor-desktop-grade-b-shell slice
rodrgds Sep 17, 2026
101979d
merge(web): integrate editor-desktop-grade-c-inspector slice
rodrgds Sep 17, 2026
dfeb448
merge(web): integrate editor-desktop-grade-d-browsers slice
rodrgds Sep 17, 2026
06fa431
merge(web): integrate editor-desktop-grade-e-text-motion slice
rodrgds Sep 17, 2026
930a718
merge(web): integrate editor-desktop-grade-f-media-export slice
rodrgds Sep 17, 2026
fbdfdc6
merge(web): integrate editor-desktop-grade-g-preview slice
rodrgds Sep 17, 2026
b0b6db2
merge(web): integrate editor-desktop-grade-h-image slice
rodrgds Sep 17, 2026
936a673
fix(web): use native button for actionable workspace rows
rodrgds Sep 17, 2026
71685c1
docs: add changelog fragment for editor density pass
rodrgds Sep 17, 2026
60d2f56
fix(web): address review findings and wire density primitives into pa…
rodrgds Sep 17, 2026
04d35ce
docs: record editor-density workstation contract
rodrgds Sep 17, 2026
bbcf14e
fix(web): pass status line content as direct children
rodrgds Sep 17, 2026
b5ae7b8
fix(web): use density menu for transition direction select
rodrgds Sep 17, 2026
1bb775a
perf(web): skip timeline recompute when scroll geometry is unchanged
rodrgds Sep 17, 2026
7e36f88
test(web): cover timeline viewport and visibility guards
rodrgds Sep 17, 2026
f8d6e84
feat(web): add click-to-land track math with tests
rodrgds Sep 17, 2026
4dee3d4
feat(web): add click-to-land pointer gesture to slider row
rodrgds Sep 17, 2026
f52f054
merge(web): integrate timeline scroll-path slice
rodrgds Sep 17, 2026
3aa257e
merge(web): integrate slider click-to-land slice
rodrgds Sep 17, 2026
cff85a4
feat(web): add overlay mode to image editor asset panel
rodrgds Sep 17, 2026
e569fef
feat(web): move transcription model detail into label tooltip
rodrgds Sep 17, 2026
a9110d6
feat(web): collapse transcript cue list into value-reporting disclosure
rodrgds Sep 17, 2026
047e250
chore(web): remove orphaned cue-list windowing after disclosure rework
rodrgds Sep 17, 2026
9224718
merge(web): integrate asset overlay slice
rodrgds Sep 17, 2026
031b639
merge(web): integrate transcript disclosure slice
rodrgds Sep 17, 2026
1a0aa68
feat(web): add PageStrip status mode with popover page grid
rodrgds Sep 17, 2026
9c88e3f
merge(web): integrate pagestrip status slice
rodrgds Sep 17, 2026
ebceb2d
docs: extend changelog for final density wave
rodrgds Sep 17, 2026
0335736
feat(web): make image asset add-grid icon-first
rodrgds Sep 17, 2026
09a8d33
feat(web): flip asset rail to overlay and pagestrip to status row
rodrgds Sep 17, 2026
6b923ec
merge(main): integrate latest remote main
rodrgds Sep 18, 2026
0d2cffc
fix(web): quantize density drags and narrow gesture targets
rodrgds Sep 18, 2026
283e019
fix(web): resolve page-strip types and cover status-row flow
rodrgds Sep 18, 2026
74b3e21
fix(web): share disclosure, guard overlay close and visibility writes
rodrgds Sep 18, 2026
f1d2d5d
fix(web): reveal panel hints on tap and restore coarse-pointer targets
rodrgds Sep 18, 2026
c6e2e97
merge(main): pick up dependency drift fixes
rodrgds Sep 18, 2026
d3e6282
fix(web): trim dead density exports, cover gestures, simplify page grid
rodrgds Sep 18, 2026
aea9921
fix(web): satisfy assertion lint in density component tests
rodrgds Sep 18, 2026
112d4f8
fix(web): unify density clones, calibrate fallow gate to green
rodrgds Sep 18, 2026
a0d1b35
fix(web): unify shape/audio/text clones, document scrub parallelism
rodrgds Sep 18, 2026
b44545c
fix(web): scope snippet definitions, narrow mediaId prop
rodrgds Sep 18, 2026
399d82b
fix(web): restore shared Slider for audio pan range (boundary contract)
rodrgds Sep 18, 2026
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
16 changes: 16 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,20 @@
// scripts/*.mjs are correct by npm semantics; triage, do not relocate.
"dev-dependencies-in-production": "warn",
},

"health": {
// Per-file ceilings for findings that are reviewed and deliberately
// kept whole. PageStrip grid cell (assessed 2026-09-18): the 54-line
// pageGrid snippet's branches (grabbed/insertion/active/drop-target
// states x a11y/dnd bindings) are inherent to a page grid cell;
// extracting the button into a child snippet relocates identical
// branchiness behind prop indirection for zero clarity gain.
"thresholdOverrides": [
{
"files": ["apps/web/src/lib/image-editor/components/page-strip.svelte"],
"maxCognitive": 25,
"reason": "Page grid cell states are inherent to the template; extraction relocates identical branchiness.",
},
],
},
}
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ Each social network has one self-hosting integration guide. Keep shared credenti
- Image layers without `color_grade_version` retain legacy Fabric adjustments. Version 1 routes layer and page-output grading through the shared editor color pipeline and the Fabric preview/export adapter. Never migrate legacy layers implicitly.
- Image Editor controller mutations preserve unchanged document, page, and layer references; shared undo entries depend on those objects remaining immutable. Apply document edits through the controller, and defer page-strip rendering until a color gesture ends.
- Image and Video Editors share wheel and curve controls, scope presentation, and curve math under `lib/components/editor-color-*` and `lib/editor-color-grade`. Each editor owns its gestures, selection, persistence, and sampled frames.
- Editor workstation controls (scrub field, slider row, disclosure, menu, knob, toolbar group, status line) live in `lib/components/editor-density/` on theme tokens at 22px fields, 25px menus and bars, and 32px primary actions. Scope 44px minimums to coarse pointers with `[@media(pointer:coarse)]`; the `[(pointer:coarse)]` form is not a valid variant. Every new production file needs a production importer or the knip reachability gate fails.
- A Video Editor sequence grade is one `sequenceColorGrade` adjustment item on its dedicated locked track. The timeline store keeps it over the full sequence range, and preview/export apply it once after compositing. Never treat it as an item-scoped adjustment layer.
- Keep secrets out of code and logs. Stored provider tokens remain encrypted.
- Provider certification identifies output profiles. Hash all of an output's authoring formats into its contract, and use the same production requirements when recording and evaluating evidence.
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/lib/components/editor-density/disclosure.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import {
Content as CollapsibleContent,
Root as CollapsibleRoot,
Trigger as CollapsibleTrigger
} from '$lib/components/ui/collapsible';
import { ThemeIcon } from '$lib/themes/icons';
import { cn } from '$lib/utils';

let {
label,
summary,
open = $bindable(false),
disabled = false,
class: className = '',
actions,
children
}: {
label: string;
summary?: string;
open?: boolean;
disabled?: boolean;
class?: string;
actions?: Snippet;
children: Snippet;
} = $props();
</script>

<CollapsibleRoot
bind:open
{disabled}
class={cn('border-b first:border-t', className)}
data-editor-disclosure
>
<div class="flex h-[var(--editor-25,25px)] items-center gap-1">
<CollapsibleTrigger
class="flex min-w-0 flex-1 items-center gap-1.5 rounded-[var(--editor-radius,5px)] px-1 text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none [@media(pointer:coarse)]:h-11"
aria-label={summary ? `${label}: ${summary}` : label}
>
<ThemeIcon
role="chevron-down"
class={cn('size-3 shrink-0 transition-transform', open && '-rotate-180')}
/>
<span class="truncate text-[11px] font-medium tracking-wide uppercase">{label}</span>
{#if !open && summary}
<span
class="min-w-0 flex-1 truncate text-right font-mono text-[10px] text-muted-foreground tabular-nums"
>{summary}</span
>
{/if}
</CollapsibleTrigger>
{#if actions}
<span class="flex shrink-0 items-center" data-editor-disclosure-actions
>{@render actions()}</span
>
{/if}
</div>
<CollapsibleContent class="pb-1.5">
{@render children()}
</CollapsibleContent>
</CollapsibleRoot>
22 changes: 22 additions & 0 deletions apps/web/src/lib/components/editor-density/editor-density.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Editor-density workstation scale.
*
* Same theme tokens, smaller metrics. Import once where editors mount
* (a slice follow-up wires this into the editor shells); every component in
* this directory already falls back to the literal px values when the scope
* class is absent, so rendering never depends on this import.
*
* Density targets (fine pointer): 22px fields, 25px menus/bars, 32px primary.
* Coarse pointers keep 44px targets via per-component pointer:coarse rules.
*/
.editor-density {
--editor-22: 22px;
--editor-25: 25px;
--editor-32: 32px;
--editor-radius: 5px;
}

/* Tabular figures everywhere a value is read at rest. */
.editor-density-nums {
font-variant-numeric: tabular-nums;
}
42 changes: 42 additions & 0 deletions apps/web/src/lib/components/editor-density/hint-button.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<script lang="ts">
import * as Popover from '$lib/components/ui/popover';
import { Button } from '$lib/components/ui/button';
import { ThemeIcon } from '$lib/themes/icons';
import { cn } from '$lib/utils';

// Tap-friendly help affordance for workstation panels. Hint text must be
// reachable by touch and pen users, so it lives in a popover instead of a
// hover-only title attribute.
let {
label,
hint,
class: className = ''
}: {
label: string;
hint: string;
class?: string;
} = $props();
</script>

<Popover.Root>
<Popover.Trigger>
{#snippet child({ props })}
<Button
{...props}
type="button"
variant="ghost"
size="icon-xs"
class={cn(
'size-[22px] text-muted-foreground hover:text-foreground [@media(pointer:coarse)]:size-11',
className
)}
aria-label={label}
>
<ThemeIcon role="help" class="size-3" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content side="top" align="start" class="max-w-64 p-2 text-xs text-muted-foreground">
{hint}
</Popover.Content>
</Popover.Root>
9 changes: 9 additions & 0 deletions apps/web/src/lib/components/editor-density/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import './editor-density.css';

export { default as Disclosure } from './disclosure.svelte';
export { default as HintButton } from './hint-button.svelte';
export { default as Knob } from './knob.svelte';
export { default as EditorMenu } from './menu.svelte';
export { default as SliderRow } from './slider-row.svelte';
export { default as StatusLine } from './status-line.svelte';
export { default as ToolbarGroup } from './toolbar-group.svelte';
230 changes: 230 additions & 0 deletions apps/web/src/lib/components/editor-density/knob.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
<script lang="ts">
import { cn } from '$lib/utils';
import { clampValue, nudgeValue } from './scrub-math';

const FULL_SWEEP_DEG = 270;
const START_DEG = -135;

let {
ariaLabel,
value,
min = 0,
max = 100,
step = 1,
size = 28,
resetValue,
disabled = false,
class: className = '',
onbegin,
onValueChange,
onValueCommit,
onValueCancel
}: {
ariaLabel: string;
value: number;
min?: number;
max?: number;
step?: number;
size?: 20 | 28 | 40 | 56;
resetValue?: number;
disabled?: boolean;
class?: string;
onbegin?: () => void;
onValueChange?: (value: number) => void;
onValueCommit?: (value: number) => void;
onValueCancel?: () => void;
} = $props();

let knob = $state<HTMLElement | null>(null);
let gestureActive = false;
let drag: {
pointerId: number;
lastAngle: number;
lastX: number;
lastY: number;
centerX: number;
centerY: number;
startValue: number;
moved: boolean;
} | null = null;

const fraction = $derived(max === min ? 0 : (clampValue(value, min, max) - min) / (max - min));
const pointerAngle = $derived(START_DEG + fraction * FULL_SWEEP_DEG);
const range = $derived(max - min);

function pointerAngleAt(
clientX: number,
clientY: number,
centerX: number,
centerY: number
): number {
return (Math.atan2(clientY - centerY, clientX - centerX) * 180) / Math.PI;
}

function angleDelta(from: number, to: number): number {
let delta = to - from;
while (delta > 180) delta -= 360;
while (delta < -180) delta += 360;
return delta;
}

function beginGesture(): void {
if (gestureActive) return;
gestureActive = true;
onbegin?.();
}

function setLive(next: number): void {
beginGesture();
onValueChange?.(clampValue(next, min, max));
}

// Snap a continuous drag value onto the step grid. Step quantizes the
// result; it must not scale the drag itself.
function quantizeToStep(raw: number): number {
if (!Number.isFinite(step) || step <= 0) return raw;
return min + Math.round((raw - min) / step) * step;
}

function commit(next: number): void {
gestureActive = false;
onValueCommit?.(clampValue(next, min, max));
}

function startDrag(event: PointerEvent): void {
if (disabled || event.button !== 0 || !knob) return;
event.preventDefault();
knob.setPointerCapture(event.pointerId);
const rect = knob.getBoundingClientRect();
drag = {
pointerId: event.pointerId,
lastAngle: pointerAngleAt(
event.clientX,
event.clientY,
rect.left + rect.width / 2,
rect.top + rect.height / 2
),
lastX: event.clientX,
lastY: event.clientY,
centerX: rect.left + rect.width / 2,
centerY: rect.top + rect.height / 2,
startValue: value,
moved: false
};
}

function moveDrag(event: PointerEvent): void {
if (!drag || drag.pointerId !== event.pointerId) return;
const speed = event.shiftKey ? 5 : event.altKey ? 0.2 : 1;
const angle = pointerAngleAt(event.clientX, event.clientY, drag.centerX, drag.centerY);
const circularDelta = (angleDelta(drag.lastAngle, angle) / FULL_SWEEP_DEG) * range;
const linearDelta = ((drag.lastX - event.clientX - (event.clientY - drag.lastY)) / 150) * range;
// Re-decide every move: the dominant gesture wins.
const delta = Math.abs(circularDelta) >= Math.abs(linearDelta) ? circularDelta : linearDelta;
drag.lastAngle = angle;
drag.lastX = event.clientX;
drag.lastY = event.clientY;
if (delta === 0) return;
drag.moved = true;
setLive(quantizeToStep(value + delta * speed));
}

function finishDrag(event: PointerEvent): void {
if (!drag || drag.pointerId !== event.pointerId) return;
const startValue = drag.startValue;
const moved = drag.moved;
drag = null;
if (knob?.hasPointerCapture(event.pointerId)) knob.releasePointerCapture(event.pointerId);
if (moved) commit(value);
else {
gestureActive = false;
onValueChange?.(startValue);
}
}

function cancelDrag(event: PointerEvent): void {
if (!drag || drag.pointerId !== event.pointerId) return;
const startValue = drag.startValue;
drag = null;
gestureActive = false;
onValueChange?.(startValue);
onValueCancel?.();
}
</script>

<div
bind:this={knob}
role="slider"
tabindex={disabled ? -1 : 0}
aria-label={ariaLabel}
aria-valuenow={Math.round(value * 100) / 100}
aria-valuemin={min}
aria-valuemax={max}
aria-disabled={disabled}
title={ariaLabel}
style="width: {size}px; height: {size}px;"
class={cn(
'shrink-0 cursor-ns-resize touch-none rounded-full border bg-muted select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none [@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11',
disabled && 'pointer-events-none opacity-50',
className
)}
onpointerdown={startDrag}
onpointermove={moveDrag}
onpointerup={finishDrag}
onpointercancel={cancelDrag}
ondblclick={() => {
if (!disabled && resetValue !== undefined) {
beginGesture();
commit(resetValue);
}
}}
onkeydown={(event) => {
if (disabled) return;
if (event.key === 'ArrowUp' || event.key === 'ArrowRight') {
event.preventDefault();
event.stopPropagation();
setLive(nudgeValue(value, 1, step, { shift: event.shiftKey, alt: event.altKey }, min, max));
} else if (event.key === 'ArrowDown' || event.key === 'ArrowLeft') {
event.preventDefault();
event.stopPropagation();
setLive(nudgeValue(value, -1, step, { shift: event.shiftKey, alt: event.altKey }, min, max));
} else if (event.key === 'Escape') {
event.stopPropagation();
gestureActive = false;
onValueCancel?.();
if (event.currentTarget instanceof HTMLElement) event.currentTarget.blur();
}
}}
onkeyup={(event) => {
if (event.key.startsWith('Arrow')) commit(value);
}}
onblur={() => {
if (gestureActive) commit(value);
}}
>
<svg viewBox="0 0 20 20" width={size} height={size} aria-hidden="true" class="block">
<circle
cx="10"
cy="10"
r="8.5"
fill="none"
stroke="currentColor"
stroke-width="1"
class="text-muted-foreground"
opacity="0.35"
/>
<g transform="rotate({pointerAngle} 10 10)">
<line
x1="10"
y1="10"
x2="10"
y2="3.5"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
class="text-foreground"
/>
</g>
<circle cx="10" cy="10" r="1.4" fill="currentColor" class="text-foreground" />
</svg>
</div>
Loading
Loading