Skip to content

Commit 73d0fa5

Browse files
committed
Implement TPS visual cue rendering across editor and teleprompter
1 parent 88313d0 commit 73d0fa5

19 files changed

+817
-94
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ Repo-specific design rules:
310310
- Prefer deleting JS files entirely when they only hold product UI behavior or duplicated constants; JS modules may exist only as thin bridges to browser APIs or external JS SDKs, with the owning workflow and state kept in C#/Blazor.
311311
- TPS front matter pasted or imported into the editor source MUST be parsed into the metadata rail automatically and removed from the visible body text instead of staying inline in the source editor.
312312
- TPS support MUST fully implement the current `design/TPS.md` contract end to end; legacy or partially compatible TPS syntax is not a supported mode, and any old incompatible behavior should be removed instead of kept behind compatibility shims.
313+
- TPS visual semantics MUST track the current TPS spec end to end: editor and reader surfaces should communicate delivery cues such as volume, emphasis, stress, speed, and delivery mode through typography, spacing, weight, and motion where appropriate, not through color alone.
313314
- For standalone cloud-storage integrations, persist provider keys, tokens, and connection metadata in browser `localStorage`; do not introduce server-side secret storage for runtime auth in this app shape.
314315
- Third-party runtime JavaScript SDKs MUST be sourced only from explicitly pinned GitHub Release tags and assets, copied into the repo, bundled locally with their runtime dependencies, and never loaded from CDNs, package registries, `latest` endpoints, or ad-hoc remote downloads at app runtime.
315316
- Repo-owned manifests, scripts, workflows, and project files that track third-party runtime JavaScript SDKs MUST point to concrete GitHub release versions and asset URLs, never floating references.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
namespace PrompterOne.Shared.Contracts;
2+
3+
public static class TpsVisualCueContracts
4+
{
5+
public const string CueOpacityVariableName = "--tps-cue-opacity";
6+
public const string CueScaleVariableName = "--tps-cue-scale";
7+
public const string CueWeightVariableName = "--tps-cue-weight";
8+
public const string DeliveryAttributeName = "data-tps-delivery";
9+
public const string DeliveryModeBuilding = "building";
10+
public const string SpeedAttributeName = "data-tps-speed";
11+
public const string SpeedCueFast = "fast";
12+
public const string SpeedCueSlow = "slow";
13+
public const string SpeedCueXfast = "xfast";
14+
public const string SpeedCueXslow = "xslow";
15+
public const string StressAttributeName = "data-tps-stress";
16+
public const string StressAttributeValue = "true";
17+
public const string VolumeAttributeName = "data-tps-volume";
18+
public const string VolumeLoud = "loud";
19+
public const string VolumeSoft = "soft";
20+
public const string VolumeWhisper = "whisper";
21+
}

src/PrompterOne.Shared/Editor/Components/EditorSourcePanel.razor.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Microsoft.Extensions.Logging.Abstractions;
55
using Microsoft.JSInterop;
66
using PrompterOne.Core.Models.Editor;
7+
using PrompterOne.Shared.Contracts;
78

89
namespace PrompterOne.Shared.Components.Editor;
910

@@ -30,6 +31,14 @@ public partial class EditorSourcePanel
3031
private bool _syncScrollAfterRender = true;
3132
private bool _visibleCanRedo;
3233
private bool _visibleCanUndo;
34+
private static readonly object SourceCueContracts = new
35+
{
36+
volumeAttributeName = TpsVisualCueContracts.VolumeAttributeName,
37+
deliveryAttributeName = TpsVisualCueContracts.DeliveryAttributeName,
38+
speedAttributeName = TpsVisualCueContracts.SpeedAttributeName,
39+
stressAttributeName = TpsVisualCueContracts.StressAttributeName,
40+
stressAttributeValue = TpsVisualCueContracts.StressAttributeValue
41+
};
3342

3443
[Parameter] public bool CanRedo { get; set; }
3544

@@ -237,7 +246,7 @@ private async Task EnsureSurfaceInteropReadyAsync()
237246
}
238247

239248
_surfaceInteropReady = await RunInitializationInteropAsync(
240-
() => Interop.InitializeAsync(_textareaRef, _overlayRef),
249+
() => Interop.InitializeAsync(_textareaRef, _overlayRef, SourceCueContracts),
241250
InitializeSurfaceFailureMessage);
242251
}
243252

@@ -251,7 +260,7 @@ private async Task SafeSyncScrollAsync()
251260
private async Task SafeRenderOverlayAsync()
252261
{
253262
_ = await RunInteropAsync(
254-
() => Interop.RenderOverlayAsync(_overlayRef, Text),
263+
() => Interop.RenderOverlayAsync(_overlayRef, Text, SourceCueContracts),
255264
InitializeSurfaceFailureMessage);
256265
}
257266

src/PrompterOne.Shared/Editor/Components/EditorSourcePanel.razor.css

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -197,18 +197,30 @@ body.theme-light .efb-menu-item[data-tip]::after {
197197
.ed-main ::deep .mk-emo-calm{color:#5EECC2;}
198198
.ed-main ::deep .mk-emo-energetic{color:#FFA050;}
199199
.ed-main ::deep .mk-emo-professional{color:#80B8FF;}
200-
.ed-main ::deep .mk-vol-loud{color:#FFB86A;font-weight:800;}
201-
.ed-main ::deep .mk-vol-soft{color:#C9DFFF;}
202-
.ed-main ::deep .mk-vol-whisper{color:#D7D1E2;font-style:italic;}
203-
.ed-main ::deep .mk-del-aside{color:#E2C89A;font-style:italic;}
204-
.ed-main ::deep .mk-del-rhetorical{color:#D7B8FF;}
205-
.ed-main ::deep .mk-del-sarcasm{color:#FFAACD;}
206-
.ed-main ::deep .mk-del-building{color:#FFD98A;font-weight:700;}
207-
.ed-main ::deep .mk-stress{text-decoration:underline;text-decoration-color:#FFD060;text-underline-offset:4px;text-decoration-thickness:2px;}
208-
.ed-main ::deep .mk-xslow{color:#FF8A8A;text-shadow:0 0 8px rgba(255,138,138,.16);}
209-
.ed-main ::deep .mk-slow{color:#FFB86A;text-shadow:0 0 8px rgba(255,184,106,.14);}
210-
.ed-main ::deep .mk-fast{color:#8ECFFF;text-shadow:0 0 8px rgba(142,207,255,.12);}
211-
.ed-main ::deep .mk-xfast{color:#8ECFFF;opacity:.84;}
200+
.ed-main ::deep [data-tps-volume],
201+
.ed-main ::deep [data-tps-delivery],
202+
.ed-main ::deep [data-tps-speed],
203+
.ed-main ::deep [data-tps-stress="true"]{
204+
--tps-cue-opacity:1;
205+
--tps-cue-scale:1;
206+
display:inline-block;
207+
font-weight:var(--tps-cue-weight, inherit);
208+
opacity:var(--tps-cue-opacity);
209+
transform:translateY(calc((1 - var(--tps-cue-scale)) * .04em)) scale(var(--tps-cue-scale));
210+
transform-origin:left 72%;
211+
}
212+
.ed-main ::deep .mk-vol-loud{color:#FFB86A;font-weight:var(--tps-cue-weight, 800);--tps-cue-scale:1.06;}
213+
.ed-main ::deep .mk-vol-soft{color:#C9DFFF;--tps-cue-opacity:.92;--tps-cue-scale:.96;}
214+
.ed-main ::deep .mk-vol-whisper{color:#D7D1E2;font-style:italic;letter-spacing:.025em;--tps-cue-opacity:.84;--tps-cue-scale:.93;}
215+
.ed-main ::deep .mk-del-aside{color:#E2C89A;font-style:italic;--tps-cue-opacity:.88;--tps-cue-scale:.96;}
216+
.ed-main ::deep .mk-del-rhetorical{color:#D7B8FF;--tps-cue-scale:1.02;}
217+
.ed-main ::deep .mk-del-sarcasm{color:#FFAACD;--tps-cue-opacity:.92;--tps-cue-scale:1.01;}
218+
.ed-main ::deep .mk-del-building{color:#FFD98A;font-weight:var(--tps-cue-weight, 700);--tps-cue-scale:1.05;--tps-cue-weight:760;}
219+
.ed-main ::deep .mk-stress{text-decoration:underline;text-decoration-color:#FFD060;text-underline-offset:4px;text-decoration-thickness:2px;--tps-cue-scale:1.07;--tps-cue-weight:820;}
220+
.ed-main ::deep .mk-xslow{color:#FF8A8A;letter-spacing:.018em;text-shadow:0 0 8px rgba(255,138,138,.16);}
221+
.ed-main ::deep .mk-slow{color:#FFB86A;letter-spacing:.012em;text-shadow:0 0 8px rgba(255,184,106,.14);}
222+
.ed-main ::deep .mk-fast{color:#8ECFFF;letter-spacing:-.01em;text-shadow:0 0 8px rgba(142,207,255,.12);}
223+
.ed-main ::deep .mk-xfast{color:#8ECFFF;letter-spacing:-.018em;opacity:.84;}
212224
.ed-main ::deep .mk-phonetic{color:#D88AFF;background-image:linear-gradient(180deg, transparent 62%, rgba(216,138,255,.12) 62%);box-shadow:inset 0 -1px 0 rgba(216,138,255,.28);}
213225
.ed-main ::deep .mk-phonetic-word{border-bottom:2px dashed rgba(216,138,255,.4);}
214226
.ed-main ::deep .mk-special{color:#E0C070;background-image:linear-gradient(180deg, transparent 60%, rgba(224,192,112,.12) 60%);box-shadow:inset 0 -1px 0 rgba(224,192,112,.22);}

src/PrompterOne.Shared/Editor/Rendering/EditorMarkupRenderer.cs

Lines changed: 94 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Text;
33
using System.Text.RegularExpressions;
44
using Microsoft.AspNetCore.Components;
5+
using PrompterOne.Shared.Contracts;
56

67
namespace PrompterOne.Shared.Rendering;
78

@@ -140,18 +141,13 @@ private void AppendStyledText(string text)
140141
var encoded = WebUtility.HtmlEncode(text)
141142
.Replace("\n", "<br>", StringComparison.Ordinal);
142143

143-
var classes = CurrentState.BuildCssClass();
144-
if (string.IsNullOrWhiteSpace(classes))
144+
if (!CurrentState.RequiresSpan())
145145
{
146146
_builder.Append(encoded);
147147
return;
148148
}
149149

150-
_builder.Append("<span class=\"")
151-
.Append(classes)
152-
.Append("\">")
153-
.Append(encoded)
154-
.Append("</span>");
150+
AppendStyledSpan(encoded, CurrentState);
155151
}
156152

157153
private void HandleTag(string rawTag)
@@ -235,21 +231,42 @@ private void HandleTag(string rawTag)
235231
if (SpeedClasses.TryGetValue(name, out var speedClass))
236232
{
237233
AppendTag(rawTag);
238-
PushScope(name, ScopeKind.Style, CurrentState with { SpeedClass = speedClass });
234+
PushScope(
235+
name,
236+
ScopeKind.Style,
237+
CurrentState with
238+
{
239+
SpeedClass = speedClass,
240+
SpeedValue = speedClass is null ? null : name.ToLowerInvariant()
241+
});
239242
return;
240243
}
241244

242245
if (VolumeClasses.TryGetValue(name, out var volumeClass))
243246
{
244247
AppendTag(rawTag);
245-
PushScope(name, ScopeKind.Style, CurrentState with { VolumeClass = volumeClass });
248+
PushScope(
249+
name,
250+
ScopeKind.Style,
251+
CurrentState with
252+
{
253+
VolumeClass = volumeClass,
254+
VolumeValue = name.ToLowerInvariant()
255+
});
246256
return;
247257
}
248258

249259
if (DeliveryClasses.TryGetValue(name, out var deliveryClass))
250260
{
251261
AppendTag(rawTag);
252-
PushScope(name, ScopeKind.Style, CurrentState with { DeliveryClass = deliveryClass });
262+
PushScope(
263+
name,
264+
ScopeKind.Style,
265+
CurrentState with
266+
{
267+
DeliveryClass = deliveryClass,
268+
DeliveryValue = name.ToLowerInvariant()
269+
});
253270
return;
254271
}
255272

@@ -324,11 +341,21 @@ private void AppendPronunciationPayload(ScopeFrame scope, RenderState parentStat
324341
.Append(guide)
325342
.Append("</span> ");
326343

327-
var cssClass = parentState.BuildCssClass("mk-phonetic-word");
328-
_builder.Append("<span class=\"")
329-
.Append(cssClass)
330-
.Append("\">")
331-
.Append(spoken)
344+
AppendStyledSpan(spoken, parentState, "mk-phonetic-word");
345+
}
346+
347+
private void AppendStyledSpan(string encodedText, RenderState state, params string[] extraClasses)
348+
{
349+
if (!state.RequiresSpan(extraClasses))
350+
{
351+
_builder.Append(encodedText);
352+
return;
353+
}
354+
355+
_builder.Append("<span");
356+
state.AppendHtmlAttributes(_builder, extraClasses);
357+
_builder.Append('>')
358+
.Append(encodedText)
332359
.Append("</span>");
333360
}
334361

@@ -395,14 +422,27 @@ private sealed class ScopeFrame(string name, EditorMarkupRenderer.ScopeKind kind
395422

396423
private sealed record RenderState(
397424
string? EmotionClass,
425+
string? VolumeValue,
398426
string? VolumeClass,
427+
string? DeliveryValue,
399428
string? DeliveryClass,
429+
string? SpeedValue,
400430
string? SpeedClass,
401431
bool IsEmphasis,
402432
bool IsHighlighted,
403433
bool IsStress)
404434
{
405-
public static readonly RenderState Default = new(null, null, null, null, false, false, false);
435+
public static readonly RenderState Default = new(null, null, null, null, null, null, null, false, false, false);
436+
437+
public bool RequiresSpan(params string[] extraClasses) =>
438+
!string.IsNullOrWhiteSpace(EmotionClass) ||
439+
!string.IsNullOrWhiteSpace(VolumeClass) ||
440+
!string.IsNullOrWhiteSpace(DeliveryClass) ||
441+
!string.IsNullOrWhiteSpace(SpeedClass) ||
442+
IsEmphasis ||
443+
IsHighlighted ||
444+
IsStress ||
445+
extraClasses.Any(static value => !string.IsNullOrWhiteSpace(value));
406446

407447
public string BuildCssClass(params string[] extraClasses)
408448
{
@@ -446,6 +486,44 @@ public string BuildCssClass(params string[] extraClasses)
446486
classes.AddRange(extraClasses.Where(static value => !string.IsNullOrWhiteSpace(value)));
447487
return string.Join(" ", classes);
448488
}
489+
490+
public void AppendHtmlAttributes(StringBuilder builder, params string[] extraClasses)
491+
{
492+
var classes = BuildCssClass(extraClasses);
493+
if (!string.IsNullOrWhiteSpace(classes))
494+
{
495+
AppendAttribute(builder, "class", classes);
496+
}
497+
498+
if (!string.IsNullOrWhiteSpace(VolumeValue))
499+
{
500+
AppendAttribute(builder, TpsVisualCueContracts.VolumeAttributeName, VolumeValue);
501+
}
502+
503+
if (!string.IsNullOrWhiteSpace(DeliveryValue))
504+
{
505+
AppendAttribute(builder, TpsVisualCueContracts.DeliveryAttributeName, DeliveryValue);
506+
}
507+
508+
if (!string.IsNullOrWhiteSpace(SpeedValue))
509+
{
510+
AppendAttribute(builder, TpsVisualCueContracts.SpeedAttributeName, SpeedValue);
511+
}
512+
513+
if (IsStress)
514+
{
515+
AppendAttribute(builder, TpsVisualCueContracts.StressAttributeName, TpsVisualCueContracts.StressAttributeValue);
516+
}
517+
}
518+
519+
private static void AppendAttribute(StringBuilder builder, string name, string value)
520+
{
521+
builder.Append(' ')
522+
.Append(name)
523+
.Append("=\"")
524+
.Append(WebUtility.HtmlEncode(value))
525+
.Append('"');
526+
}
449527
}
450528

451529
private enum ScopeKind

src/PrompterOne.Shared/Editor/Services/EditorInterop.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ public sealed class EditorInterop(IJSRuntime jsRuntime)
99
{
1010
private readonly IJSRuntime _jsRuntime = jsRuntime;
1111

12-
public ValueTask<bool> InitializeAsync(ElementReference textarea, ElementReference overlay) =>
13-
_jsRuntime.InvokeAsync<bool>(EditorSurfaceInteropMethodNames.Initialize, textarea, overlay);
12+
public ValueTask<bool> InitializeAsync(ElementReference textarea, ElementReference overlay, object cueContracts) =>
13+
_jsRuntime.InvokeAsync<bool>(EditorSurfaceInteropMethodNames.Initialize, textarea, overlay, cueContracts);
1414

15-
public ValueTask RenderOverlayAsync(ElementReference overlay, string text) =>
16-
_jsRuntime.InvokeVoidAsync(EditorSurfaceInteropMethodNames.RenderOverlay, overlay, text ?? string.Empty);
15+
public ValueTask RenderOverlayAsync(ElementReference overlay, string text, object cueContracts) =>
16+
_jsRuntime.InvokeVoidAsync(EditorSurfaceInteropMethodNames.RenderOverlay, overlay, text ?? string.Empty, cueContracts);
1717

1818
public ValueTask SyncScrollAsync(ElementReference textarea, ElementReference overlay) =>
1919
_jsRuntime.InvokeVoidAsync(EditorSurfaceInteropMethodNames.SyncScroll, textarea, overlay);

0 commit comments

Comments
 (0)