Skip to content

Commit 7ac308e

Browse files
committed
Add reader alignment modes and sync TPS bounds
1 parent 4c23a67 commit 7ac308e

29 files changed

+447
-43
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ Rule format:
8080
- Repo-owned docs, README, ADRs, and AGENTS files must not contain local usernames, home-directory paths, or personal machine-specific references; use repo-relative paths or neutral wording instead.
8181
- Public-facing screenshots and any screenshot-generating or screenshot-asserting tests must use English-visible content so README, docs, and release assets stay globally readable and consistent.
8282
- Public-facing screenshots that include camera or preview feeds must not ship mirrored or reversed readable text; choose or configure the capture so visible text reads correctly in the final asset.
83+
- Teleprompter reader text alignment must expose explicit left, center, and right modes, default to left alignment, and keep the left-aligned mode optically centered by offsetting the text mass away from a visibly left-heavy block.
8384

8485
## Rules to Follow (Mandatory)
8586

docs/Features/ReaderRuntime.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ The important contracts are:
1717
- Teleprompter block transitions always move in one upward direction: the outgoing card exits up and the incoming card rises from below.
1818
- Teleprompter controls stay readable at rest; they must not fade until they become unusable.
1919
- Teleprompter route styles must already be present on first paint from the app host document; a late style attach during route entry is a regression.
20-
- Teleprompter user-adjusted font size, text width, focal position, and camera preference survive reloads through the shared user-settings contract.
20+
- Teleprompter exposes explicit left, center, and right text alignment modes, defaults to left alignment, and keeps left- or right-aligned text optically inset so the text mass still reads near the center of the stage.
21+
- Teleprompter user-adjusted font size, text width, text alignment, focal position, and camera preference survive reloads through the shared user-settings contract.
2122

2223
## Flow
2324

@@ -58,25 +59,30 @@ flowchart LR
5859
- `teleprompter` applies TPS inline emotion colors only when a word is explicitly tagged; untagged reader words must stay on the base reader palette instead of inheriting an implicit `neutral` word class.
5960
- `teleprompter` keeps TPS inline colors visible even when a phrase group is active or the active word is highlighted.
6061
- `teleprompter` keeps the active focus word calm: the active word may be brighter than its neighbors, but upcoming and read words stay gently dimmed and active-word glow stays restrained enough to avoid a bright moving patch.
61-
- `teleprompter` persists font scale, text width, focal point, and camera auto-start changes through `IUserSettingsStore` and restores them from stored `ReaderSettings` during bootstrap.
62+
- `teleprompter` exposes explicit left, center, and right text-alignment controls on the reader chrome; left alignment is the default and uses an optical inset instead of hard-gluing the first line to the left edge of the readable column.
63+
- `teleprompter` persists font scale, text width, text alignment, focal point, and camera auto-start changes through `IUserSettingsStore` and restores them from stored `ReaderSettings` during bootstrap.
6264
- `teleprompter` keeps forward block jumps on the straight reference path, but backward block jumps reverse that motion so the returning previous block comes in from above while the outgoing current block drops away.
6365
- `teleprompter` uses one smooth paragraph realignment while words advance inside a card, but the first word of a newly entered card is already pre-centered so block changes do not trigger a second correction pass.
6466
- `teleprompter` loads its feature stylesheet from the initial host `<head>` instead of relying on route-time `HeadContent`, so direct opens and route transitions share the same first-paint styling.
67+
- `teleprompter` clamps TPS `base_wpm` to the canonical `80..220` runtime range and ignores out-of-range header WPM overrides, matching the current C# TPS contract instead of accepting unsupported playback speeds.
6568

6669
## Verification
6770

6871
- bUnit verifies teleprompter background-camera markup and readable phrase groups.
6972
- bUnit verifies product-launch TPS modifiers survive into teleprompter word markup, timing, and pronunciation metadata.
7073
- bUnit verifies custom TPS `speed_offsets` front matter and `[normal]` resets survive into teleprompter word classes, styles, and effective-WPM metadata.
71-
- bUnit verifies teleprompter restores persisted reader width, focal position, and font size and saves reader layout/camera preference changes back to stored `ReaderSettings`.
74+
- bUnit verifies teleprompter restores persisted reader width, text alignment, focal position, and font size and saves reader layout/camera preference changes back to stored `ReaderSettings`.
7275
- Core tests verify TPS scripts generate RSVP phrase groups.
7376
- Core tests verify shorthand inline WPM scopes such as `[180WPM]...[/180WPM]` survive nested tags.
7477
- Core tests verify nested `speed_offsets:` front matter is parsed and applied to `xslow` / `slow` / `fast` / `xfast` scope math.
78+
- Core tests verify TPS `base_wpm` clamps to the canonical runtime bounds and out-of-range header WPM overrides fall back to the clamped base value.
7579
- Core tests verify legacy reader-settings payloads without `FocalPointPercent` deserialize with the default focal-point value.
80+
- Core tests verify legacy reader-settings payloads without `TextAlignment` deserialize with the default left-alignment value.
7681
- Playwright verifies ORP centering, pause-boundary left-context continuity, fixed-lane stability across short and long words, and stop-at-end versus loop-enabled playback in `learn`.
7782
- Playwright verifies there is no teleprompter overlay camera box and that phrase groups do not overflow.
7883
- Playwright verifies the teleprompter camera button attaches and detaches a real synthetic `MediaStream` on the background video layer.
7984
- Playwright verifies the full `Product Launch` teleprompter scenario, including visible controls, TPS formatting parity, screenshot artifacts, and aligned post-transition playback.
85+
- Playwright verifies teleprompter left, center, and right alignment controls switch real browser text layout and that the default left-aligned mode keeps the visible text mass near the stage center instead of drifting too far left.
8086
- Playwright verifies a dedicated reader-timing probe for both `learn` and `teleprompter`, recording emitted words in the browser and checking that sequence order and elapsed delays match the rendered timing contract word by word.
8187
- Playwright verifies the teleprompter stylesheet is already registered in `document.styleSheets` before the app navigates into the teleprompter route.
8288
- Playwright verifies custom TPS speed offsets change computed teleprompter `letter-spacing` while `[normal]` words reset back to neutral spacing and timing.

src/PrompterOne.Core/Tps/Services/ScriptCompiler.cs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -388,7 +388,7 @@ private static bool TryHandleSlashPause(
388388
FlushPhrase(phrases, currentPhrase);
389389
words.Add(CreateControlWord(
390390
isPause: true,
391-
pauseDuration: hasNextSlash ? 600 : 300,
391+
pauseDuration: hasNextSlash ? TpsSpec.MediumPauseDurationMs : TpsSpec.ShortPauseDurationMs,
392392
inherited: inherited));
393393
if (hasNextSlash)
394394
{
@@ -642,10 +642,10 @@ private static IReadOnlyDictionary<string, int> ResolveSpeedOffsets(IReadOnlyDic
642642
{
643643
return new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
644644
{
645-
[TpsSpec.Tags.Xslow] = ResolveSpeedOffset(metadata, TpsSpec.FrontMatterKeys.SpeedOffsetsXslow, TpsSpec.DefaultXslowOffset),
646-
[TpsSpec.Tags.Slow] = ResolveSpeedOffset(metadata, TpsSpec.FrontMatterKeys.SpeedOffsetsSlow, TpsSpec.DefaultSlowOffset),
647-
[TpsSpec.Tags.Fast] = ResolveSpeedOffset(metadata, TpsSpec.FrontMatterKeys.SpeedOffsetsFast, TpsSpec.DefaultFastOffset),
648-
[TpsSpec.Tags.Xfast] = ResolveSpeedOffset(metadata, TpsSpec.FrontMatterKeys.SpeedOffsetsXfast, TpsSpec.DefaultXfastOffset)
645+
[TpsSpec.Tags.Xslow] = ResolveSpeedOffset(metadata, TpsSpec.FrontMatterKeys.SpeedOffsetsXslow, TpsSpec.DefaultSpeedOffsets[TpsSpec.Tags.Xslow]),
646+
[TpsSpec.Tags.Slow] = ResolveSpeedOffset(metadata, TpsSpec.FrontMatterKeys.SpeedOffsetsSlow, TpsSpec.DefaultSpeedOffsets[TpsSpec.Tags.Slow]),
647+
[TpsSpec.Tags.Fast] = ResolveSpeedOffset(metadata, TpsSpec.FrontMatterKeys.SpeedOffsetsFast, TpsSpec.DefaultSpeedOffsets[TpsSpec.Tags.Fast]),
648+
[TpsSpec.Tags.Xfast] = ResolveSpeedOffset(metadata, TpsSpec.FrontMatterKeys.SpeedOffsetsXfast, TpsSpec.DefaultSpeedOffsets[TpsSpec.Tags.Xfast])
649649
};
650650
}
651651

@@ -660,10 +660,13 @@ private static int ResolveBaseWpm(IReadOnlyDictionary<string, string> metadata)
660660
{
661661
return metadata.TryGetValue(TpsSpec.FrontMatterKeys.BaseWpm, out var value) &&
662662
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
663-
? Math.Max(1, parsed)
663+
? ClampSupportedWpm(parsed)
664664
: TpsSpec.DefaultBaseWpm;
665665
}
666666

667+
private static int ClampSupportedWpm(int candidate) =>
668+
Math.Clamp(candidate, TpsSpec.MinimumWpm, TpsSpec.MaximumWpm);
669+
667670
private static string ResolveEmotion(string? candidate, string fallback)
668671
{
669672
var normalized = NormalizeValue(candidate)?.ToLowerInvariant();

src/PrompterOne.Core/Tps/Services/TpsParser.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -362,15 +362,15 @@ private static bool TryParseHeaderWpm(string value, out int? wpm)
362362
var numberPart = normalized[..^TpsSpec.WpmSuffix.Length];
363363
if (int.TryParse(numberPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedWithSuffix))
364364
{
365-
wpm = parsedWithSuffix;
365+
wpm = ResolveSupportedHeaderWpm(parsedWithSuffix);
366366
}
367367

368368
return true;
369369
}
370370

371371
if (int.TryParse(normalized, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
372372
{
373-
wpm = parsed;
373+
wpm = ResolveSupportedHeaderWpm(parsed);
374374
return true;
375375
}
376376

@@ -576,10 +576,18 @@ private static int ResolveBaseWpm(IReadOnlyDictionary<string, string> metadata)
576576
{
577577
return metadata.TryGetValue(TpsSpec.FrontMatterKeys.BaseWpm, out var value) &&
578578
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
579-
? Math.Max(1, parsed)
579+
? ClampSupportedWpm(parsed)
580580
: TpsSpec.DefaultBaseWpm;
581581
}
582582

583+
private static int ClampSupportedWpm(int candidate) =>
584+
Math.Clamp(candidate, TpsSpec.MinimumWpm, TpsSpec.MaximumWpm);
585+
586+
private static int? ResolveSupportedHeaderWpm(int candidate) =>
587+
candidate < TpsSpec.MinimumWpm || candidate > TpsSpec.MaximumWpm
588+
? null
589+
: candidate;
590+
583591
private static string ResolveEmotion(string? emotion, string fallback)
584592
{
585593
var normalized = NormalizeValue(emotion)?.ToLowerInvariant();

src/PrompterOne.Core/Tps/Services/TpsSpec.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ namespace PrompterOne.Core.Services;
33
internal static class TpsSpec
44
{
55
public const int DefaultBaseWpm = 140;
6+
public const int MaximumWpm = 220;
7+
public const int MinimumWpm = 80;
8+
public const int MediumPauseDurationMs = 600;
9+
public const int ShortPauseDurationMs = 300;
610
public const string DefaultEmotion = "neutral";
711
public const string DefaultImplicitSegmentName = "Content";
812
public const string DefaultProfile = "Actor";
@@ -65,6 +69,20 @@ public static class Tags
6569
public const string Xslow = "xslow";
6670
}
6771

72+
public static class DiagnosticCodes
73+
{
74+
public const string InvalidFrontMatter = "invalid-front-matter";
75+
public const string InvalidHeader = "invalid-header";
76+
public const string InvalidHeaderParameter = "invalid-header-parameter";
77+
public const string InvalidPause = "invalid-pause";
78+
public const string InvalidTagArgument = "invalid-tag-argument";
79+
public const string InvalidWpm = "invalid-wpm";
80+
public const string MismatchedClosingTag = "mismatched-closing-tag";
81+
public const string UnclosedTag = "unclosed-tag";
82+
public const string UnknownTag = "unknown-tag";
83+
public const string UnterminatedTag = "unterminated-tag";
84+
}
85+
6886
public static IReadOnlySet<string> Emotions { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
6987
{
7088
"neutral",
@@ -96,6 +114,22 @@ public static class Tags
96114
Tags.Whisper
97115
};
98116

117+
public static IReadOnlySet<string> RelativeSpeedTags { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
118+
{
119+
Tags.Xslow,
120+
Tags.Slow,
121+
Tags.Fast,
122+
Tags.Xfast,
123+
Tags.Normal
124+
};
125+
126+
public static IReadOnlySet<string> EditPointPriorities { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
127+
{
128+
"high",
129+
"medium",
130+
"low"
131+
};
132+
99133
public static IReadOnlyDictionary<string, int> DefaultSpeedOffsets { get; } = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
100134
{
101135
[Tags.Xslow] = DefaultXslowOffset,
@@ -119,6 +153,22 @@ public static class Tags
119153
["calm"] = new("#0F766E", "#F0FDFA", "#99F6E4"),
120154
["energetic"] = new("#C2410C", "#FFF7ED", "#FDBA74")
121155
};
156+
157+
public static IReadOnlyDictionary<string, string> EmotionHeadCues { get; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
158+
{
159+
[DefaultEmotion] = "H0",
160+
["calm"] = "H0",
161+
["professional"] = "H9",
162+
["focused"] = "H5",
163+
["motivational"] = "H9",
164+
["urgent"] = "H4",
165+
["concerned"] = "H1",
166+
["sad"] = "H1",
167+
["warm"] = "H7",
168+
["happy"] = "H6",
169+
["excited"] = "H6",
170+
["energetic"] = "H8"
171+
};
122172
}
123173

124174
internal sealed record EmotionPalette(string Accent, string Text, string Background);

src/PrompterOne.Core/Workspace/Models/ReaderSettings.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public sealed record ReaderSettings(
88
bool MirrorText = ReaderSettingsDefaults.MirrorText,
99
bool MirrorVertical = ReaderSettingsDefaults.MirrorVertical,
1010
ReaderTextOrientation TextOrientation = ReaderSettingsDefaults.TextOrientation,
11+
ReaderTextAlignment TextAlignment = ReaderSettingsDefaults.TextAlignment,
1112
bool ShowFocusLine = ReaderSettingsDefaults.ShowFocusLine,
1213
bool ShowProgress = ReaderSettingsDefaults.ShowProgress,
1314
bool ShowCameraScene = ReaderSettingsDefaults.ShowCameraScene,

src/PrompterOne.Core/Workspace/Models/ReaderSettingsDefaults.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ public static class ReaderSettingsDefaults
99
public const bool MirrorText = false;
1010
public const bool MirrorVertical = false;
1111
public const ReaderTextOrientation TextOrientation = ReaderTextOrientation.Landscape;
12+
public const ReaderTextAlignment TextAlignment = ReaderTextAlignment.Left;
1213
public const bool ShowFocusLine = true;
1314
public const bool ShowProgress = true;
1415
public const bool ShowCameraScene = false;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace PrompterOne.Core.Models.Workspace;
2+
3+
public enum ReaderTextAlignment
4+
{
5+
Left = 0,
6+
Center = 1,
7+
Right = 2
8+
}

src/PrompterOne.Shared/Contracts/UiTestIds.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,10 @@ public static class Learn
189189
public static class Teleprompter
190190
{
191191
public const string Back = "teleprompter-back";
192+
public const string AlignmentCenter = "teleprompter-alignment-center";
193+
public const string AlignmentControls = "teleprompter-alignment-controls";
194+
public const string AlignmentLeft = "teleprompter-alignment-left";
195+
public const string AlignmentRight = "teleprompter-alignment-right";
192196
public const string CameraBackground = "teleprompter-camera-layer-primary";
193197
public const string CameraToggle = "teleprompter-camera-toggle";
194198
public const string ClusterWrap = "teleprompter-cluster-wrap";

src/PrompterOne.Shared/Teleprompter/Pages/TeleprompterPage.ReaderPreferences.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ await PersistReaderSettingsAsync(currentSettings => currentSettings with
1414
FocalPointPercent = _readerFocalPointPercent,
1515
MirrorText = _isReaderMirrorHorizontal,
1616
MirrorVertical = _isReaderMirrorVertical,
17+
TextAlignment = _readerTextAlignment,
1718
TextOrientation = _readerTextOrientation
1819
});
1920
}

0 commit comments

Comments
 (0)