Skip to content
Draft
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
4 changes: 4 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Binary file added docs/figure-annotations.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions docs/figures.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

![maps example page showing an annotation callout for TryGetValue](figure-annotations.png)

---

## Reference — all figures

| Figure name | Paint method | Canvas (w×h) | Attached to |
Expand Down
10 changes: 10 additions & 0 deletions src/dotnetbyexample.Tests/NoccoTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
69 changes: 69 additions & 0 deletions src/dotnetbyexample/Marginalia/AnnotationAttachments.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Collections.Generic;

namespace dotnetbyexample.Marginalia;

/// <summary>
/// Describes a text annotation attached to an example page.
/// </summary>
public readonly record struct AnnotationAttachment(
string Text,
string Note,
string DirectionClass,
string ColorClass,
string? Context = null);

/// <summary>
/// Maps example directory slugs to optional annotation callouts.
/// </summary>
public static class AnnotationAttachments
{
private static readonly Dictionary<string, IReadOnlyList<AnnotationAttachment>> 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));
}
}
4 changes: 4 additions & 0 deletions src/dotnetbyexample/Nocco.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ private static async Task GenerateHtml(string source, Dictionary<string, List<Se
// Look up any figures attached to this example
var slug = new DirectoryInfo(source).Name;
var figureBanners = FigureAttachments.GetFigures(slug).ToList();
var exampleAnnotations = AnnotationAttachments.GetAnnotations(slug).ToList();

var html = await htmlRenderer.Dispatcher.InvokeAsync(async () =>
{
Expand All @@ -160,10 +161,12 @@ private static async Task GenerateHtml(string source, Dictionary<string, List<Se
{ "Title", Path.GetFileName(source) },
{ "PathToCss", Path.Combine(pathToRoot, "nocco.css").Replace('\\', '/') },
{ "PathToJs", Path.Combine(pathToRoot, "prettify.js").Replace('\\', '/') },
{ "PathToAnnotationsCss", Path.Combine(pathToRoot, "neat-annotations.css").Replace('\\', '/') },
{ "GetSourcePath", getSourcePath },
{ "Files", files },
{ "Runner", runner },
{ "FigureBanners", figureBanners },
{ "ExampleAnnotations", exampleAnnotations },
};

var parameters = ParameterView.FromDictionary(dictionary);
Expand Down Expand Up @@ -333,6 +336,7 @@ public static async Task GenerateAsync(string examplesRoot = "examples", string
var executingDirectory = GetExecutingDirectory();
File.Copy(Path.Combine(executingDirectory, "Resources", "nocco.css"), Path.Combine(siteFolder, "nocco.css"), true);
File.Copy(Path.Combine(executingDirectory, "Resources", "nocco.js"), Path.Combine(siteFolder, "nocco.js"), true);
File.Copy(Path.Combine(executingDirectory, "Resources", "neat-annotations.css"), Path.Combine(siteFolder, "neat-annotations.css"), true);
File.Copy(Path.Combine(executingDirectory, "Resources", "prettify.js"), Path.Combine(siteFolder, "prettify.js"), true);

var directories = Directory.GetDirectories(examplesRoot, "*", SearchOption.TopDirectoryOnly);
Expand Down
22 changes: 22 additions & 0 deletions src/dotnetbyexample/Resources/Webpage.razor
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
<link rel="icon" type="image/x-icon" href="favicon.ico" />

<link href="@(PathToCss)" rel="stylesheet" media="all" type="text/css" />
<link href="https://fonts.googleapis.com/css2?family=Shantell+Sans:wght@400;500;600&amp;display=swap" rel="stylesheet" />
<link href="@(PathToAnnotationsCss)" rel="stylesheet" media="all" type="text/css" />
<script src="@(PathToJs)" type="text/javascript"></script>
</head>
<body onload="prettyPrint()">
Expand Down Expand Up @@ -94,6 +96,22 @@
</div>
}

@if (ExampleAnnotations.Count > 0)
{
<section class="annotation-banner" aria-label="Example annotations">
@foreach (var (text, note, cssClass, context) in ExampleAnnotations)
{
<p class="annotation-banner__item">
@if (!string.IsNullOrWhiteSpace(context))
{
<span class="annotation-banner__context">@context: </span>
}
<span class="@cssClass" data-note="@note">@text</span>
</p>
}
</section>
}

<table cellpadding="0" cellspacing="0">
<thead>
<tr>
Expand Down Expand Up @@ -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<string, List<Section>> Files { get; set; } = new();
Expand All @@ -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<string, string> GetSourcePath { get; set; } = static _ => string.Empty;
Expand Down
163 changes: 163 additions & 0 deletions src/dotnetbyexample/Resources/neat-annotations.css
Original file line number Diff line number Diff line change
@@ -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: "<angle>";
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)); }
20 changes: 20 additions & 0 deletions src/dotnetbyexample/Resources/nocco.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
3 changes: 3 additions & 0 deletions src/dotnetbyexample/dotnetbyexample.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
<None Update="Resources\nocco.js">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Resources\neat-annotations.css">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Resources\prettify.js">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
Expand Down
Loading