diff --git a/docs/README.md b/docs/README.md index 67956e2..1a9a72b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,3 +8,7 @@ https://css-tricks.com/functional-css-tabs-revisited/ How to Add Copy to Clipboard Buttons to Code Blocks in Hugo March 22, 2019 https://www.dannyguo.com/blog/how-to-add-copy-to-clipboard-buttons-to-code-blocks-in-hugo/ + +Neat Annotations +https://neat-annotations.syabro.com/ +https://github.com/syabro/neat-annotations diff --git a/docs/figure-annotations.png b/docs/figure-annotations.png new file mode 100644 index 0000000..6b611e8 Binary files /dev/null and b/docs/figure-annotations.png differ diff --git a/docs/figures.md b/docs/figures.md index 8656183..de55e5b 100644 --- a/docs/figures.md +++ b/docs/figures.md @@ -1258,6 +1258,16 @@ A .NET object serialised to XML and deserialised back to an equivalent object. --- +## Annotation screenshot example + +The generated `maps` page now includes text callouts rendered with Neat +Annotations. This screenshot shows an annotation attached to +`TryGetValue`, between the figure banner and runner output. + + + +--- + ## Reference — all figures | Figure name | Paint method | Canvas (w×h) | Attached to | diff --git a/src/dotnetbyexample.Tests/NoccoTests.cs b/src/dotnetbyexample.Tests/NoccoTests.cs index dcb8b4d..1a924d5 100644 --- a/src/dotnetbyexample.Tests/NoccoTests.cs +++ b/src/dotnetbyexample.Tests/NoccoTests.cs @@ -45,6 +45,16 @@ public void FigureAttachments_GetFigures_ReturnsConfiguredFigure() Assert.All(figures, figure => Assert.False(string.IsNullOrWhiteSpace(figure.Svg))); } + [Fact] + public void AnnotationAttachments_GetAnnotations_ReturnsConfiguredAnnotation() + { + var annotations = AnnotationAttachments.GetAnnotations("maps").ToList(); + + Assert.NotEmpty(annotations); + Assert.Contains(annotations, annotation => annotation.Text.Contains("TryGetValue", StringComparison.OrdinalIgnoreCase)); + Assert.All(annotations, annotation => Assert.Contains("ann", annotation.CssClass, StringComparison.Ordinal)); + } + [Fact] public async Task GenerateAsync_GeneratesIndexAndExamplePage() { diff --git a/src/dotnetbyexample/Marginalia/AnnotationAttachments.cs b/src/dotnetbyexample/Marginalia/AnnotationAttachments.cs new file mode 100644 index 0000000..cc133c4 --- /dev/null +++ b/src/dotnetbyexample/Marginalia/AnnotationAttachments.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; + +namespace dotnetbyexample.Marginalia; + +/// +/// Describes a text annotation attached to an example page. +/// +public readonly record struct AnnotationAttachment( + string Text, + string Note, + string DirectionClass, + string ColorClass, + string? Context = null); + +/// +/// Maps example directory slugs to optional annotation callouts. +/// +public static class AnnotationAttachments +{ + private static readonly Dictionary> Registry = + new(StringComparer.OrdinalIgnoreCase) + { + ["hello-world"] = new[] + { + new AnnotationAttachment( + "Console.WriteLine", + "writes to standard output", + "ann-s", + "ann-amber", + "Core API") + }, + ["maps"] = new[] + { + new AnnotationAttachment( + "TryGetValue", + "read safely without exceptions", + "ann-s", + "ann-blue", + "Lookup"), + new AnnotationAttachment( + "delete", + "remove a key/value entry", + "ann-se", + "ann-green", + "Mutation") + }, + ["goroutines"] = new[] + { + new AnnotationAttachment( + "go", + "starts concurrent work", + "ann-ne", + "ann-purple", + "Concurrency") + } + }; + + public static IEnumerable<(string Text, string Note, string CssClass, string? Context)> GetAnnotations(string slug) + { + if (!Registry.TryGetValue(slug, out var annotations)) + return []; + + return annotations.Select(annotation => ( + annotation.Text, + annotation.Note, + $"ann {annotation.DirectionClass} {annotation.ColorClass}", + annotation.Context)); + } +} diff --git a/src/dotnetbyexample/Nocco.cs b/src/dotnetbyexample/Nocco.cs index ebcc47c..9274aff 100644 --- a/src/dotnetbyexample/Nocco.cs +++ b/src/dotnetbyexample/Nocco.cs @@ -150,6 +150,7 @@ private static async Task GenerateHtml(string source, Dictionary { @@ -160,10 +161,12 @@ private static async Task GenerateHtml(string source, Dictionary + + @@ -94,6 +96,22 @@ } + @if (ExampleAnnotations.Count > 0) + { + + @foreach (var (text, note, cssClass, context) in ExampleAnnotations) + { + + @if (!string.IsNullOrWhiteSpace(context)) + { + @context: + } + @text + + } + + } + @@ -140,6 +158,8 @@ public string PathToCss { get; set; } = String.Empty; [Parameter] public string PathToJs { get; set; } = String.Empty; + [Parameter] + public string PathToAnnotationsCss { get; set; } = String.Empty; [Parameter] public Dictionary> Files { get; set; } = new(); @@ -149,6 +169,8 @@ [Parameter] public List<(string Svg, string Caption)> FigureBanners { get; set; } = new(); + [Parameter] + public List<(string Text, string Note, string CssClass, string? Context)> ExampleAnnotations { get; set; } = new(); [Parameter] public Func GetSourcePath { get; set; } = static _ => string.Empty; diff --git a/src/dotnetbyexample/Resources/neat-annotations.css b/src/dotnetbyexample/Resources/neat-annotations.css new file mode 100644 index 0000000..e504ec2 --- /dev/null +++ b/src/dotnetbyexample/Resources/neat-annotations.css @@ -0,0 +1,163 @@ +/* neat-annotations — neat hand-drawn CSS annotations: arrows + handwritten labels. + https://github.com/syabro/neat-annotations · MIT license. + Labels look best with the Shantell Sans font (falls back to system cursive). */ + +.ann { + --ann-color: light-dark(#898584, #b8b8bd); + --ann-mark: light-dark( + color-mix(in oklch, var(--ann-color) 10%, transparent), + oklch(from var(--ann-color) l calc(c * 1.35) h / 24%) + ); + --ann-font: 'Shantell Sans', cursive; + --ann-target-gap: 5px; + --ann-label-gap: 6px; + --ann-lower-label-gap: -4px; + --ann-label-max-width: 150px; + --ann-arrow-x: 0px; + --ann-text-x: 0px; + --ann-arrow-y: 0px; + --ann-text-y: 5px; + --ann-rotate: -4deg; + position: relative; + display: inline-block; + overflow: visible; + line-height: 1.15; + border-radius: 3px; + background: var(--ann-mark); + box-shadow: + -0.3em 0 0 0 var(--ann-mark), + 0.3em 0 0 0 var(--ann-mark); +} + +.ann::after { + content: attr(data-note); + position: absolute; + z-index: 4; + width: max-content; + max-width: var(--ann-label-max-width); + transform: rotate(var(--ann-rotate)); + font-family: var(--ann-font); + font-size: 0.95rem; + font-weight: 400; + line-height: 1.05; + color: var(--ann-color); + opacity: .9; + white-space: normal; + overflow-wrap: anywhere; + pointer-events: none; +} + +.ann::before { + content: ""; + position: absolute; + z-index: 3; + width: 46px; + height: 38px; + transform-origin: 50% 50%; + -webkit-mask: var(--_ann-arrow-mask) center / contain no-repeat; + mask: var(--_ann-arrow-mask) center / contain no-repeat; + background: var(--ann-color); + opacity: .9; + pointer-events: none; +} + +.ann:not([data-note])::before, +.ann:not([data-note])::after, +.ann[data-note=""]::before, +.ann[data-note=""]::after { display: none; } + +/* Amber text needs darker contrast, but the highlight uses bright yellow (#ffb000) + to avoid a muddy background. color-mix with transparent creates an even marker tint. */ +.ann-amber { + --ann-color: light-dark(#b8751a, #f0b45a); + --ann-mark: light-dark( + color-mix(in oklch, #ffb000 14%, transparent), + color-mix(in oklch, #ffb000 22%, transparent) + ); +} +.ann-blue { --ann-color: light-dark(#3b72c4, #78a9ef); } +.ann-green { --ann-color: light-dark(#2f8f5b, #67c792); } +.ann-red { --ann-color: light-dark(#c45a38, #ee8a6e); } +.ann-purple { --ann-color: light-dark(#8657c8, #b998ef); } +.ann-rainbow { + --ann-color: oklch(0.65 0.28 var(--ann-rainbow-hue, 350deg)); +} +.ann-no-mark { --ann-mark: transparent; } + +@property --ann-rainbow-hue { + syntax: ""; + inherits: false; + initial-value: 350deg; +} + +@keyframes ann-rainbow-cycle { to { --ann-rainbow-hue: 710deg; } } +@media (prefers-reduced-motion: no-preference) { + .ann-rainbow { animation: ann-rainbow-cycle 3s linear infinite; } +} + +@media (forced-colors: active) { + .ann::before { + background: CanvasText; + forced-color-adjust: none; + } +} + +.ann-n::before { + left: calc(50% - 23px + var(--ann-arrow-x)); + top: calc(100% + var(--ann-target-gap) - 4px + var(--ann-arrow-y)); + --_ann-arrow-mask: url("data:image/svg+xml,%3Csvg width='46' height='38' viewBox='0 0 46 38' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M23 35 C22 26 22 15 23 4' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3Cpath d='M17 10 L23 3 L29 10' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); +} +.ann-n::after { left: calc(50% + var(--ann-text-x)); top: calc(100% + var(--ann-target-gap) + 31px + var(--ann-lower-label-gap) + var(--ann-text-y)); transform: translateX(-50%) rotate(var(--ann-rotate)); } + +.ann-ne::before { + left: calc(50% - 40px + var(--ann-arrow-x)); + top: calc(100% + var(--ann-target-gap) - 6px + var(--ann-arrow-y)); + transform: translateX(-10%); + --_ann-arrow-mask: url("data:image/svg+xml,%3Csvg width='46' height='38' viewBox='0 0 46 38' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 32 C16 30 28 18 40 6' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3Cpath d='M30 10 L40 6 L36 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); +} +.ann-ne::after { left: calc(50% - 34px + var(--ann-text-x)); top: calc(100% + var(--ann-target-gap) + 26px + var(--ann-lower-label-gap) + var(--ann-text-y)); transform: translateX(-60%) rotate(var(--ann-rotate)); } + +.ann-e::before { + left: calc(-1 * var(--ann-target-gap) - 43px + var(--ann-arrow-x)); + top: calc(50% - 19px + var(--ann-arrow-y)); + --_ann-arrow-mask: url("data:image/svg+xml,%3Csvg width='46' height='38' viewBox='0 0 46 38' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M4 19 C15 18 31 18 43 19' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3Cpath d='M36 13 L43 19 L36 25' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); +} +.ann-e::after { right: calc(100% + var(--ann-target-gap) + 39px + var(--ann-label-gap) - var(--ann-text-x)); top: calc(50% - 5px + var(--ann-text-y)); transform: translateY(-50%) rotate(var(--ann-rotate)); } + +.ann-se::before { + left: calc(50% - 40px + var(--ann-arrow-x)); + top: calc(-1 * var(--ann-target-gap) - 32px + var(--ann-arrow-y)); + transform: translateX(-10%); + --_ann-arrow-mask: url("data:image/svg+xml,%3Csvg width='46' height='38' viewBox='0 0 46 38' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6 6 C16 8 28 20 40 32' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3Cpath d='M36 22 L40 32 L30 28' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); +} +.ann-se::after { left: calc(50% - 34px + var(--ann-text-x)); bottom: calc(100% + var(--ann-target-gap) + 26px + var(--ann-label-gap) - var(--ann-text-y)); transform: translateX(-60%) rotate(var(--ann-rotate)); } + +.ann-s::before { + left: calc(50% - 23px + var(--ann-arrow-x)); + top: calc(-1 * var(--ann-target-gap) - 35px + var(--ann-arrow-y)); + --_ann-arrow-mask: url("data:image/svg+xml,%3Csvg width='46' height='38' viewBox='0 0 46 38' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M23 4 C22 13 22 24 23 35' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3Cpath d='M17 29 L23 36 L29 29' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); +} +.ann-s::after { left: calc(50% + var(--ann-text-x)); bottom: calc(100% + var(--ann-target-gap) + 31px + var(--ann-label-gap) - var(--ann-text-y)); transform: translateX(-50%) rotate(var(--ann-rotate)); } + +.ann-sw::before { + left: calc(50% - 6px + var(--ann-arrow-x)); + top: calc(-1 * var(--ann-target-gap) - 32px + var(--ann-arrow-y)); + transform: translateX(10%); + --_ann-arrow-mask: url("data:image/svg+xml,%3Csvg width='46' height='38' viewBox='0 0 46 38' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M40 6 C30 8 18 20 6 32' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3Cpath d='M10 22 L6 32 L16 28' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); +} +.ann-sw::after { left: calc(50% + 34px + var(--ann-text-x)); bottom: calc(100% + var(--ann-target-gap) + 26px + var(--ann-label-gap) - var(--ann-text-y)); transform: translateX(-40%) rotate(var(--ann-rotate)); } + +.ann-w::before { + left: calc(100% + var(--ann-target-gap) - 3px + var(--ann-arrow-x)); + top: calc(50% - 19px + var(--ann-arrow-y)); + --_ann-arrow-mask: url("data:image/svg+xml,%3Csvg width='46' height='38' viewBox='0 0 46 38' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M43 19 C32 18 15 18 3 19' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3Cpath d='M10 13 L3 19 L10 25' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); +} +.ann-w::after { left: calc(100% + var(--ann-target-gap) + 40px + var(--ann-label-gap) + var(--ann-text-x)); top: calc(50% - 5px + var(--ann-text-y)); transform: translateY(-50%) rotate(var(--ann-rotate)); } + +.ann-nw::before { + left: calc(50% - 6px + var(--ann-arrow-x)); + top: calc(100% + var(--ann-target-gap) - 6px + var(--ann-arrow-y)); + transform: translateX(10%); + --_ann-arrow-mask: url("data:image/svg+xml,%3Csvg width='46' height='38' viewBox='0 0 46 38' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M40 32 C30 30 18 18 6 6' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3Cpath d='M16 10 L6 6 L10 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); +} +.ann-nw::after { left: calc(50% + 34px + var(--ann-text-x)); top: calc(100% + var(--ann-target-gap) + 26px + var(--ann-lower-label-gap) + var(--ann-text-y)); transform: translateX(-40%) rotate(var(--ann-rotate)); } diff --git a/src/dotnetbyexample/Resources/nocco.css b/src/dotnetbyexample/Resources/nocco.css index 8da528d..7c6cb36 100644 --- a/src/dotnetbyexample/Resources/nocco.css +++ b/src/dotnetbyexample/Resources/nocco.css @@ -311,4 +311,24 @@ table td { font-size: 0.92rem; font-style: italic; max-width: 44ch; +} + +/*---------------------- Text Annotations -----------------------------*/ +.annotation-banner { + margin: 8px 50px 28px; + padding: 20px 0; + display: flex; + flex-wrap: wrap; + gap: 28px 40px; +} + +.annotation-banner__item { + margin: 0; + min-width: 220px; + line-height: 1.6; +} + +.annotation-banner__context { + color: rgba(38, 26, 59, 0.85); + font-weight: 600; } \ No newline at end of file diff --git a/src/dotnetbyexample/dotnetbyexample.csproj b/src/dotnetbyexample/dotnetbyexample.csproj index ee82ed6..da63496 100644 --- a/src/dotnetbyexample/dotnetbyexample.csproj +++ b/src/dotnetbyexample/dotnetbyexample.csproj @@ -28,6 +28,9 @@ Always + + Always + Always
+ @if (!string.IsNullOrWhiteSpace(context)) + { + @context: + } + @text +