TipToe 0.2.0

dotnet add package TipToe --version 0.2.0
                    
NuGet\Install-Package TipToe -Version 0.2.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="TipToe" Version="0.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="TipToe" Version="0.2.0" />
                    
Directory.Packages.props
<PackageReference Include="TipToe" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add TipToe --version 0.2.0
                    
#r "nuget: TipToe, 0.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package TipToe@0.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=TipToe&version=0.2.0
                    
Install as a Cake Addin
#tool nuget:?package=TipToe&version=0.2.0
                    
Install as a Cake Tool

TipToe

A quiet, careful and deliberate WinUI TeachingTip orchestrator.

Declare your teaching tips once. TipToe decides when — and whether — each one is worth showing.

using TipToe;

var guide = Guide.Attach(this, RootGrid);

guide.Add(new Tip
{
    Id       = "search",
    Title    = "Find anything",
    Subtitle = "Filters the list as you type.",
    Target   = () => SearchBox,
    When     = () => TaskCount >= 5,
});

…and, in the feature itself:

guide.Exercise("search");

That is the whole contract. TipToe will not show that tip if the user has already used search, already dismissed it, is mid-drag, is looking at another app, saw a different tip four seconds ago, saw this tip earlier today, or the anchor is not currently on screen.

The principle throughout: a teaching tip at the wrong moment is worse than no teaching tip at all. Almost everything the library does is a reason not to show something.

The sample app: a tip anchored to a button, with the inspector explaining every tip's verdict

<sub>The runnable sample — samples/TipToe.Sample — with the inspector docked beside it. Every verdict on the right is computed by the same code that decides what to show.</sub>

Install

dotnet add package TipToe

net8.0-windows10.0.19041.0. Works in packaged and unpackaged apps. One AnyCPU assembly, no resource index, no per-architecture assets.

Try it

git clone https://github.com/arcadiogarcia/TipToe
dotnet run --project TipToe/samples/TipToe.Sample -p:Platform=x64

A small task list with five tips and the inspector beside it, on shortened timings so the whole lifecycle is watchable. Every snippet below is that app's real code. What to do with it, and where each pattern lives.

Decide what deserves a tip

Before any of the API: the sample app has nine or so features and ships five tips. It teaches pressing Enter, and it does not teach the button labelled Add. It teaches double-click to rename, and it does not teach the button labelled Star.

Two rules produce that:

  1. Teach what is invisible. A tip that points at a labelled button to restate its label is the purest form of the noise this library exists to avoid.
  2. Teach at the moment the user has the question. Every tip is gated on the state that makes it worth reading — the user typing, a task vanishing, a list getting long enough to need filtering.

Everything below is machinery for the second rule. The first one is yours.

When a tip appears

A tip is shown only when every one of these is true, checked in this order:

Gate Verdict when it fails
1 Tips are enabled TipsDisabled
2 The user has not exercised the feature AlreadyExercised
3 The user has not dismissed the tip Dismissed
4 It is under its show budget (MaxShows, default 3) MaxShowsReached
5 Its prerequisites have been exercised PrerequisiteNotMet
6 Its When condition is true ConditionNotMet
7 It has not been shown within RepeatDelay (default 1 day) RepeatDelay
8 No other tip is showing, or this one interrupts it AnotherTipShowing
9 CooldownBetweenTips has elapsed (default 2 s) Cooldown
10 The session cap is not reached (default: none) SessionLimit
11 StartupGrace has elapsed (default 2 s) StartupGrace
12 The window is active WindowNotActive
13 No suppressor is active Suppressed
14 The user has been idle for UserIdleFor (default 2 s) UserNotIdle
15 The anchor resolves and is on screen NoAnchor

The order is load-bearing — durable facts about the user first, so a retired tip reports retired; the expensive anchor walk last — and a test walks the whole ladder to prove the code and this table have not drifted apart.

Gates 8–11 are the only ones a tip can opt out of, with Interrupts — see Correcting a mistake.

Inspect() reports which of these each tip is currently sitting on, computed through the same code path the runtime uses — so what it says and what happens cannot disagree.

Nothing shows a tip except a re-evaluation. There is no event handler anywhere that reacts to something by teaching. See docs/triggers.md for what causes an evaluation, what restarts the idle clock, the gate ladder as a diagram, and the state machine of a single tip.

Telling TipToe about your app

Suppressors — "not now", named so a blocked tip can explain itself. Both of the sample's read state the app already had:

guide.AddSuppressor("composing", () => NewTaskBox.Text.Length > 0);
guide.AddSuppressor("dialog",    () => _dialogOpen);

While the user is typing a new task, every tip's verdict reads Suppressed - composing. Open the rename dialog and they all read Suppressed - dialog, and anything already on screen is retired — a tip pointing at a control behind a modal is pointing at something the user cannot reach.

A tip can opt out of one with Ignores, which is how a tip about typing survives the suppressor that exists to stop tips arriving mid-type:

new Tip
{
    Id              = "quick-add",
    Title           = "Press Enter to add",
    Subtitle        = "Keep typing to add the next one without leaving the keyboard.",
    Target          = () => NewTaskBox,
    When            = () => NewTaskBox.Text.Length > 0,
    RequireUserIdle = false,        // comments on what the user is doing *right now*
    Ignores         = ["composing"],
}

Both opt-outs are load-bearing here. Without Ignores the tip is suppressed by its own trigger condition; without RequireUserIdle = false it arrives after the user has given up and reached for the mouse, which is the exact thing it was trying to save them.

Correcting a mistake

Some tips are not onboarding — they answer something the user just did wrong. Set Interrupts:

new Tip
{
    Id              = "roll-by-dragging",
    Title           = "Drag a die out to roll it",
    Subtitle        = "Clicking does nothing — flick it out of the window instead.",
    Target          = () => LastClickedDie,
    Priority        = -1,           // outranks the onboarding tips it may displace
    RequireUserIdle = false,        // the user has just clicked, so they are not idle
    Ignores         = ["engaged"],
    Interrupts      = true,
}

Interrupts says this tip answers something the user just did, and buys exactly two things:

  • it may take the screen from a tip of strictly lower priority (a higher Priority number), which closes with TipCloseKind.Superseded;
  • it is exempt from the three gates that pace the conversation rather than judge the tip — Cooldown, SessionLimit and StartupGrace.

Everything else still applies, deliberately: a tip the user has exercised, dismissed or exhausted stays gone, and When, Prerequisites, RepeatDelay, un-Ignoresd suppressors, the window being active and the anchor resolving all still gate it. Interrupts overrides the library's manners, never the user's decisions.

The strictly-lower-priority rule settles ties in favour of the tip the user is already reading, and makes an interrupt loop impossible by construction — two tips can never each outrank the other. Displacing is not instant: the showing tip is asked to close and the replacement is chosen again once it has, so a correction the user has meanwhile made unnecessary simply never appears.

Exercised — call from the feature, never from near the tip, so a user who discovered it on their own is never taught it afterwards. Safe to call every time; only the first has any effect. How narrow the call is matters:

private void OnNewTaskKeyDown(object sender, KeyRoutedEventArgs e)
{
    if (e.Key != VirtualKey.Enter) return;

    AddFromBox();
    guide.Exercise("quick-add");   // the Add *button* deliberately does not
}

The tip teaches the shortcut, so only the shortcut retires it. Wiring Exercise into AddFromBox would retire the tip for someone who has only ever clicked the button — the one person who still needs it.

SequencingPrerequisites says "not until they have done that", declaratively:

new Tip
{
    Id            = "rename",
    Title         = "Double-click to rename",
    Target        = () => TaskList,
    Prerequisites = ["search"],
    When          = () => TaskList.SelectedItem is not null,
    MaxShows      = 1,
}

Expressing that inside When would behave identically and diagnose worse: the inspector would say ConditionNotMet instead of naming the tip that is actually holding this one back.

Activity from elsewhere — a single window is not the whole app:

secondWindow.PointerPressed += (_, _) => guide.ReportActivity();

Without it, a user busy in a second window is indistinguishable from a user who has walked away, and the guide happily decides they are idle enough to teach.

Options

Every value has a defensible default; nothing is required.

var guide = Guide.Attach(window, host, new TipToeOptions
{
    UserIdleFor         = TimeSpan.FromSeconds(2),
    StartupGrace        = TimeSpan.FromSeconds(2),
    CooldownBetweenTips = TimeSpan.FromSeconds(2),
    RepeatDelay         = TimeSpan.FromDays(1),
    MaxTipsPerSession   = 0,            // unlimited
    DefaultMaxShows     = 3,
    StateRevision       = 1,            // bump to discard everything persisted
    Log                 = m => Debug.WriteLine(m),
    Presenter           = new TeachingTipPresenter { StyleTip = t => t.Background = MyBrush },
});

Validate() runs at attach and throws on a configuration that could never work — a guide that runs perfectly and shows nothing is a bad way to find that out.

UserIdleFor and StartupGrace both count from Attach, so they overlap: the first tip lands after max(UserIdleFor, StartupGrace) plus a poll, not their sum — ≈2.0–2.5 s at these defaults, where the two coincide and StartupGrace therefore adds nothing. See How long until the first tip before tuning either.

Bring your own control

TipToe owns whether and when; it does not own what it looks like. Implement ITipPresenter to use a custom coach-mark, callout or inline banner, globally or for a single tip:

public sealed class MyCoachMark : ITipPresenter
{
    public void Initialize(Panel host) { ... }
    public bool IsShowing => ...;
    public void Show(TipPresentation p) { ... }
    public void Hide() { ... }
    public event EventHandler<TipClosedEventArgs>? Closed;
}

TeachingTipPresenter is the default and carries all the WinUI-specific handling, so an app that wants none of it pays nothing for it.

Storage

Defaults to app-local settings when the app has package identity, and a JSON file under %LOCALAPPDATA% when it does not. Supply your own ITipStore to put tip state anywhere else — it is a three-member string-to-string interface.

Debugging

foreach (var d in guide.Inspect())
    Debug.WriteLine(d);   // command-palette: UserNotIdle (3.4s left) [shown 1]

or drop in the panel:

DebugPane.Content = new TipsInspector { Guide = guide };

<img src="docs/images/tips-inspector.png" alt="The tips inspector, listing each tip's verdict" width="380" />

It lists every tip, why each is or is not showing, and offers per-tip and global resets. It only polls while it is on screen.

Two habits make it read correctly: check IsShowing before Block (a tip on screen still reports the gate its own show closed behind it), and treat Detail as optional — it is only populated for verdicts with a specific cause, like the suppressor's name or the time left on a delay.

Cost

One DispatcherQueueTimer at 500 ms, and only while it can do something:

  • stopped whenever the window is not active,
  • stopped permanently once every tip has been exercised, dismissed or exhausted — the normal state of a returning user's install, where the library costs nothing at all,
  • otherwise it runs, because eligibility is time-based ("idle for two seconds", "not shown today") and nothing raises an event when a duration elapses.

No XAML, no resource index, no background thread, no telemetry, no network.

Why it behaves the way it does

See docs/design-notes.md — presence vs engagement vs idleness, refunding tips that never rendered, why GetLastInputInfo was rejected, and why the package contains no XAML.

docs/triggers.md is the operational companion: what causes an evaluation, what restarts the idle clock, and the state machine of a single tip.

License

MIT © Arcadio Garcia

Product Compatible and additional computed target framework versions.
.NET net8.0-windows10.0.19041 is compatible.  net9.0-windows was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.0 92 8/15/2026
0.1.5 101 8/7/2026
0.1.4 93 8/5/2026