diff --git a/InterlinedList/Models/AiListArtifactPayload.cs b/InterlinedList/Models/AiListArtifactPayload.cs
new file mode 100644
index 0000000..1ab7aa3
--- /dev/null
+++ b/InterlinedList/Models/AiListArtifactPayload.cs
@@ -0,0 +1,64 @@
+using System.Text.Json;
+
+namespace InterlinedList.Models;
+
+///
+/// The kind: "list" artifact on its way out — what a user's edits
+/// to a Powered Template preview get serialized into for
+/// POST /api/ai/generate.
+///
+/// Why this exists alongside . That type is
+/// the read side: it deserializes a /suggest response, so its
+/// Rows are Dictionary<string, JsonElement> — every cell
+/// already a parsed JSON value. Cells a user has just typed aren't: they're
+/// strings, numbers, bools and nulls that the client decides on per column
+/// type. Forcing those through would mean serializing
+/// each cell to a document and re-parsing it one value at a time, purely to
+/// satisfy the read type. So the write side takes object? cells and lets
+/// do it once, for the whole artifact.
+///
+/// The wire shape is identical to what /suggest returned — verified live
+/// 2026-09-16:
+///
+/// { "kind":"list", "title":"Reading List", "description":"Track books…",
+/// "dsl":{ "name":…, "description":…, "fields":[…] },
+/// "rows":[ {"title":"The Hobbit","author":"J.R.R. Tolkien","status":"Finished","rating":5},
+/// {"title":"Project Hail Mary","author":"Andy Weir","status":"Reading","rating":null} ] }
+///
+/// Note rows are flat maps keyed by field key, and null is a legitimate
+/// cell value — so unset cells are sent as null rather than omitted.
+///
+public sealed class AiListArtifactPayload : IAiArtifactPayload
+{
+ public AiArtifactKind Kind => AiArtifactKind.List;
+
+ public string Title { get; init; } = "";
+
+ public string? Description { get; init; }
+
+ ///
+ /// The List Schema DSL object, rebuilt by
+ /// . Stays a
+ /// because the DSL is only partially reverse engineered and every member
+ /// this client doesn't understand has to survive the round trip untouched.
+ ///
+ public JsonElement? Dsl { get; init; }
+
+ /// Starter rows: one flat map per row, keyed by field key.
+ public List> Rows { get; init; } = new();
+
+ public object ToWire()
+ {
+ var wire = new Dictionary
+ {
+ ["kind"] = Kind.ToWire(),
+ ["title"] = Title
+ };
+
+ if (Description is { Length: > 0 }) wire["description"] = Description;
+ if (Dsl is { } dsl && dsl.ValueKind != JsonValueKind.Undefined) wire["dsl"] = dsl;
+ if (Rows.Count > 0) wire["rows"] = Rows;
+
+ return wire;
+ }
+}
diff --git a/InterlinedList/Models/AiListDsl.cs b/InterlinedList/Models/AiListDsl.cs
new file mode 100644
index 0000000..f1fc3ab
--- /dev/null
+++ b/InterlinedList/Models/AiListDsl.cs
@@ -0,0 +1,253 @@
+using System.Text.Json;
+
+namespace InterlinedList.Models;
+
+///
+/// A readable, editable view over the dsl object inside a
+/// powered_template list artifact — enough to render the proposed
+/// columns (type, label, required) in a table and let a user fix them
+/// before confirming, which is what #14 asks for and what a raw JSON blob
+/// can't give you.
+///
+/// Live-verified shape (POST /api/ai/suggest, feature
+/// powered_template, input "a reading list with title, author, status,
+/// and rating", 2026-09-16 — one quota unit):
+///
+/// "dsl": {
+/// "name": "Reading List",
+/// "description": "A personal list to track books, …",
+/// "fields": [
+/// { "key":"title", "type":"text", "label":"Title", "required":true, "displayOrder":0 },
+/// { "key":"author", "type":"text", "label":"Author", "required":true, "displayOrder":1 },
+/// { "key":"status", "type":"select", "label":"Status", "required":true, "displayOrder":2,
+/// "options":["To Read","Reading","Finished","Abandoned"], "defaultValue":"To Read" },
+/// { "key":"rating", "type":"number", "label":"Rating (1-5)", "required":false, "displayOrder":3,
+/// "visibility":{ "condition":{ "field":"status","operator":"equals","value":"Finished" } } }
+/// ]
+/// }
+///
+/// Before this, the list artifact was contract-transcribed only (#137's doc
+/// comment says as much) — the dsl wrapper's name/description
+/// members and the per-field displayOrder, options,
+/// defaultValue and visibility.condition members were not on
+/// record anywhere in this repo until now.
+///
+/// Why every field keeps its raw element. CLAUDE.md records the List
+/// Schema DSL as only partially reverse engineered, and the probe above proves
+/// it: visibility.condition and defaultValue showed up unannounced
+/// on the very first call. So this type is deliberately a partial
+/// projection — it parses the five members the column editor needs and
+/// round-trips everything else byte-for-byte out of . Editing a
+/// label must not silently drop a conditional-visibility rule the model wrote,
+/// and /generate re-validates the DSL server-side, so sending back exactly what
+/// came out (minus the deliberate edits) is the only safe default.
+///
+public sealed class AiListDsl
+{
+ /// The DSL's own name. Distinct from the artifact's title, which is what the list is called.
+ public string? Name { get; init; }
+
+ public string? Description { get; init; }
+
+ public List Fields { get; init; } = new();
+
+ /// The whole dsl object as it arrived, so unknown top-level members survive an edit.
+ public JsonElement Raw { get; init; }
+
+ /// Documented ceiling: a DSL carries at most 20 fields.
+ public const int MaxFields = 20;
+
+ ///
+ /// Documented key rule: ^[a-z][a-z0-9_-]*$. Mirrored so a
+ /// user-added column can be given a legal key locally instead of learning
+ /// it was illegal from a failed /generate — which would cost a quota unit.
+ ///
+ public static bool IsValidKey(string? key) =>
+ key is { Length: > 0 }
+ && key[0] is >= 'a' and <= 'z'
+ && key.All(c => c is >= 'a' and <= 'z' or >= '0' and <= '9' or '_' or '-');
+
+ /// Slugify a user-typed label into a legal key, or null if nothing usable survives.
+ public static string? KeyFromLabel(string? label)
+ {
+ if (string.IsNullOrWhiteSpace(label)) return null;
+
+ var chars = label.Trim().ToLowerInvariant()
+ .Select(c => c is >= 'a' and <= 'z' or >= '0' and <= '9' ? c : '_')
+ .ToArray();
+
+ var slug = new string(chars).Trim('_');
+ while (slug.Contains("__")) slug = slug.Replace("__", "_");
+
+ // A key must start with a letter; prefix rather than drop a leading digit.
+ if (slug.Length > 0 && slug[0] is >= '0' and <= '9') slug = "f_" + slug;
+
+ return IsValidKey(slug) ? slug : null;
+ }
+
+ public static AiListDsl? FromJson(JsonElement? element)
+ {
+ if (element is not { } dsl || dsl.ValueKind != JsonValueKind.Object) return null;
+
+ var fields = new List();
+ if (dsl.TryGetProperty("fields", out var fieldsElement) && fieldsElement.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var field in fieldsElement.EnumerateArray())
+ {
+ if (field.ValueKind != JsonValueKind.Object) continue;
+ fields.Add(AiListDslField.FromJson(field));
+ }
+ }
+
+ return new AiListDsl
+ {
+ Name = dsl.StringOrNull("name"),
+ Description = dsl.StringOrNull("description"),
+ Fields = fields.OrderBy(f => f.DisplayOrder).ToList(),
+ Raw = dsl.Clone()
+ };
+ }
+
+ ///
+ /// Rebuild the dsl object from an edited field set, preserving every
+ /// top-level member that isn't fields (or the two this type owns).
+ /// is re-numbered into displayOrder so a
+ /// user who deleted a column doesn't leave a gap in the sequence.
+ ///
+ public JsonElement ToElement(string? name, string? description, IReadOnlyList fields)
+ {
+ ArgumentNullException.ThrowIfNull(fields);
+
+ var wire = new Dictionary();
+
+ if (Raw.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var property in Raw.EnumerateObject())
+ {
+ if (property.NameEquals("fields") || property.NameEquals("name") || property.NameEquals("description"))
+ continue;
+ wire[property.Name] = property.Value;
+ }
+ }
+
+ if (name is { Length: > 0 }) wire["name"] = name;
+ if (description is { Length: > 0 }) wire["description"] = description;
+
+ wire["fields"] = fields
+ .Select((field, index) => field.ToWire(index))
+ .ToList();
+
+ return AiJson.ToElement(wire);
+ }
+
+ /// An empty DSL, for building one from scratch if a suggestion came back without one.
+ public static AiListDsl Empty() => new() { Raw = AiJson.ToElement(new Dictionary()) };
+}
+
+///
+/// One proposed column. The four members #14's preview has to show and let a
+/// user change — , , ,
+/// — are parsed; everything else (options,
+/// defaultValue, visibility, and whatever the DSL grows next)
+/// rides along untouched in .
+///
+public sealed class AiListDslField
+{
+ /// Stable identifier, also the key each starter row uses. ^[a-z][a-z0-9_-]*$.
+ public string Key { get; init; } = "";
+
+ /// Wire type. Observed live: text, number, select.
+ public string Type { get; init; } = DefaultType;
+
+ /// Human label shown as the column header.
+ public string Label { get; init; } = "";
+
+ public bool Required { get; init; }
+
+ public int DisplayOrder { get; init; }
+
+ /// The field object exactly as it arrived (empty for a user-added column).
+ public JsonElement Raw { get; init; }
+
+ public const string DefaultType = "text";
+
+ ///
+ /// The field types this client has actually seen the server emit. It is
+ /// explicitly not the complete DSL type set — the DSL is only
+ /// partially reverse engineered, so a column editor offers these plus
+ /// whatever types the artifact in hand already uses, and invents nothing.
+ ///
+ public static IReadOnlyList ObservedTypes { get; } = new[] { "text", "number", "select" };
+
+ /// Options for a select column, read out of for display.
+ public IReadOnlyList Options
+ {
+ get
+ {
+ if (Raw.ValueKind != JsonValueKind.Object
+ || !Raw.TryGetProperty("options", out var options)
+ || options.ValueKind != JsonValueKind.Array)
+ return Array.Empty();
+
+ return options.EnumerateArray()
+ .Select(o => o.ValueKind == JsonValueKind.String ? o.GetString() : o.ToString())
+ .Where(o => !string.IsNullOrEmpty(o))
+ .Select(o => o!)
+ .ToList();
+ }
+ }
+
+ ///
+ /// True when this field carries DSL members the column editor doesn't
+ /// surface (options, defaultValue, a visibility condition, …). The preview
+ /// says so, so a user isn't surprised that a column does more than its
+ /// three editable attributes suggest.
+ ///
+ public bool HasExtraRules =>
+ Raw.ValueKind == JsonValueKind.Object
+ && Raw.EnumerateObject().Any(p =>
+ !p.NameEquals("key") && !p.NameEquals("type") && !p.NameEquals("label")
+ && !p.NameEquals("required") && !p.NameEquals("displayOrder"));
+
+ public static AiListDslField FromJson(JsonElement field) => new()
+ {
+ Key = field.StringOrNull("key") ?? "",
+ Type = field.StringOrNull("type") ?? DefaultType,
+ Label = field.StringOrNull("label") ?? field.StringOrNull("key") ?? "",
+ Required = field.TryGetProperty("required", out var required) && required.ValueKind == JsonValueKind.True,
+ DisplayOrder = field.TryGetProperty("displayOrder", out var order)
+ && order.ValueKind == JsonValueKind.Number
+ && order.TryGetInt32(out var value)
+ ? value
+ : int.MaxValue,
+ Raw = field.Clone()
+ };
+
+ ///
+ /// Serialize back, overwriting only the five members this type owns and
+ /// copying every other member across verbatim.
+ ///
+ public Dictionary ToWire(int displayOrder)
+ {
+ var wire = new Dictionary();
+
+ if (Raw.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var property in Raw.EnumerateObject())
+ {
+ if (property.NameEquals("key") || property.NameEquals("type") || property.NameEquals("label")
+ || property.NameEquals("required") || property.NameEquals("displayOrder"))
+ continue;
+ wire[property.Name] = property.Value;
+ }
+ }
+
+ wire["key"] = Key;
+ wire["type"] = Type;
+ wire["label"] = Label;
+ wire["required"] = Required;
+ wire["displayOrder"] = displayOrder;
+
+ return wire;
+ }
+}
diff --git a/InterlinedList/ViewModels/AiListPreviewViewModels.cs b/InterlinedList/ViewModels/AiListPreviewViewModels.cs
new file mode 100644
index 0000000..a2ef61e
--- /dev/null
+++ b/InterlinedList/ViewModels/AiListPreviewViewModels.cs
@@ -0,0 +1,243 @@
+using System.Collections.ObjectModel;
+using System.Globalization;
+using System.Text.Json;
+using CommunityToolkit.Mvvm.ComponentModel;
+using InterlinedList.Models;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// One editable column in a Powered Template preview. Surfaces exactly the
+/// three attributes #14 asks for — type, label, required — and nothing else.
+///
+/// is deliberately not editable. Every starter row
+/// addresses its cells by field key, so letting a user retype a key would
+/// silently orphan that column's data. Labels are what people actually want to
+/// change; a key is only ever chosen once, when a column is added, and is
+/// slugified into the documented ^[a-z][a-z0-9_-]*$ shape at that point.
+///
+public sealed partial class AiListColumnViewModel : ObservableObject
+{
+ private readonly AiListDslField _source;
+
+ public AiListColumnViewModel(AiListDslField source, IEnumerable? extraTypes = null)
+ {
+ _source = source ?? throw new ArgumentNullException(nameof(source));
+
+ Key = source.Key;
+ label = source.Label.Length > 0 ? source.Label : source.Key;
+ type = source.Type;
+ required = source.Required;
+
+ // Offer the types this client has actually seen the server emit, plus
+ // whatever types this artifact already uses — never an invented one.
+ // The DSL is only partially reverse engineered (CLAUDE.md), and a type
+ // the server rejects would cost a quota unit to discover.
+ AvailableTypes = AiListDslField.ObservedTypes
+ .Concat(extraTypes ?? Array.Empty())
+ .Append(source.Type)
+ .Where(t => !string.IsNullOrWhiteSpace(t))
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(t => t, StringComparer.Ordinal)
+ .ToList();
+ }
+
+ /// Stable field key. Fixed for the lifetime of the column — see the type remarks.
+ public string Key { get; }
+
+ [ObservableProperty]
+ private string label;
+
+ [ObservableProperty]
+ private string type;
+
+ [ObservableProperty]
+ private bool required;
+
+ public IReadOnlyList AvailableTypes { get; }
+
+ ///
+ /// A note when the model attached DSL rules this editor doesn't show
+ /// (options, defaultValue, a visibility.condition).
+ /// They're round-tripped untouched, and saying so beats a user wondering
+ /// where the dropdown choices went.
+ ///
+ public string? ExtraRulesNote
+ {
+ get
+ {
+ if (!_source.HasExtraRules) return null;
+
+ var options = _source.Options;
+ return options.Count > 0
+ ? $"choices: {string.Join(" / ", options)}"
+ : "has extra rules (kept as-is)";
+ }
+ }
+
+ public bool HasExtraRules => _source.HasExtraRules;
+
+ /// Project the edits back onto the source field, preserving its unparsed members.
+ public AiListDslField ToField() => new()
+ {
+ Key = Key,
+ Type = Type,
+ Label = Label,
+ Required = Required,
+ DisplayOrder = _source.DisplayOrder,
+ Raw = _source.Raw
+ };
+
+ /// A column the user added by hand. Its raw element is empty, so it has no hidden rules.
+ public static AiListColumnViewModel NewColumn(string key, string label, IEnumerable? extraTypes = null)
+ => new(new AiListDslField
+ {
+ Key = key,
+ Label = label,
+ Type = AiListDslField.DefaultType,
+ Required = false,
+ DisplayOrder = int.MaxValue,
+ Raw = default
+ }, extraTypes);
+}
+
+///
+/// One cell of a starter row.
+///
+/// Unedited cells round-trip byte-for-byte. The preview has to show
+/// values as text (a WPF TextBox holds a string, not a JSON number), but
+/// re-serializing "5" back into JSON is a guess about whether it was
+/// 5, 5.0 or "5". So this keeps the original
+/// and only re-types a cell the user actually
+/// changed — which is most of them, never.
+///
+public sealed partial class AiListCellViewModel : ObservableObject
+{
+ private readonly JsonElement _original;
+ private readonly string _originalText;
+
+ public AiListCellViewModel(string columnKey, JsonElement? original)
+ {
+ ColumnKey = columnKey;
+ _original = original ?? default;
+ _originalText = ToText(_original);
+ text = _originalText;
+ }
+
+ public string ColumnKey { get; }
+
+ [ObservableProperty]
+ private string text;
+
+ public bool WasEdited => !string.Equals(Text, _originalText, StringComparison.Ordinal);
+
+ ///
+ /// The value to send. An untouched cell returns its original element
+ /// verbatim; an edited one is re-typed from ,
+ /// falling back to a string rather than dropping input the client can't
+ /// parse as a number.
+ ///
+ public object? ToWireValue(string columnType)
+ {
+ if (!WasEdited)
+ return _original.ValueKind == JsonValueKind.Undefined ? null : _original;
+
+ if (string.IsNullOrWhiteSpace(Text)) return null;
+
+ var value = Text.Trim();
+
+ switch (columnType)
+ {
+ case "number":
+ if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var whole))
+ return whole;
+ if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var real))
+ return real;
+ // Keep what they typed. The server validates starter rows and
+ // drops the ones that fail rather than failing the artifact, so
+ // a bad number costs a row, not the list.
+ return value;
+
+ case "boolean":
+ case "checkbox":
+ return bool.TryParse(value, out var flag) ? flag : value;
+
+ default:
+ return value;
+ }
+ }
+
+ private static string ToText(JsonElement element) => element.ValueKind switch
+ {
+ JsonValueKind.Undefined or JsonValueKind.Null => "",
+ JsonValueKind.String => element.GetString() ?? "",
+ JsonValueKind.True => "true",
+ JsonValueKind.False => "false",
+ JsonValueKind.Number => element.GetRawText(),
+ // An array/object inside a starter row cell has never been observed;
+ // show its JSON so at least nothing is hidden from the user.
+ _ => element.GetRawText()
+ };
+}
+
+///
+/// One starter row, as a cell per column in column order. The row's own cells
+/// are rebuilt whenever a column is added or removed, so the table stays
+/// rectangular and the header always lines up with the body.
+///
+public sealed class AiListRowViewModel
+{
+ public ObservableCollection Cells { get; } = new();
+
+ public static AiListRowViewModel FromWire(
+ IReadOnlyList columns,
+ IReadOnlyDictionary? values)
+ {
+ var row = new AiListRowViewModel();
+ foreach (var column in columns)
+ {
+ JsonElement? value = values is not null && values.TryGetValue(column.Key, out var found) ? found : null;
+ row.Cells.Add(new AiListCellViewModel(column.Key, value));
+ }
+ return row;
+ }
+
+ public static AiListRowViewModel Blank(IReadOnlyList columns)
+ {
+ var row = new AiListRowViewModel();
+ foreach (var column in columns)
+ row.Cells.Add(new AiListCellViewModel(column.Key, null));
+ return row;
+ }
+
+ public void AddCellFor(AiListColumnViewModel column)
+ => Cells.Add(new AiListCellViewModel(column.Key, null));
+
+ public void RemoveCellFor(AiListColumnViewModel column)
+ {
+ var cell = Cells.FirstOrDefault(c => c.ColumnKey == column.Key);
+ if (cell is not null) Cells.Remove(cell);
+ }
+
+ /// True when every cell is blank — an empty row is worth dropping rather than sending.
+ public bool IsEmpty => Cells.All(c => string.IsNullOrWhiteSpace(c.Text));
+
+ ///
+ /// The row object /generate expects: a flat map keyed by field key
+ /// (verified live — starter rows came back as
+ /// {"title":"The Hobbit","author":"J.R.R. Tolkien","status":"Finished","rating":5},
+ /// with null for an unset value).
+ ///
+ public Dictionary ToWire(IReadOnlyList columns)
+ {
+ var wire = new Dictionary();
+
+ foreach (var column in columns)
+ {
+ var cell = Cells.FirstOrDefault(c => c.ColumnKey == column.Key);
+ wire[column.Key] = cell?.ToWireValue(column.Type);
+ }
+
+ return wire;
+ }
+}
diff --git a/InterlinedList/ViewModels/PoweredTemplatePanelViewModel.cs b/InterlinedList/ViewModels/PoweredTemplatePanelViewModel.cs
new file mode 100644
index 0000000..c9d2753
--- /dev/null
+++ b/InterlinedList/ViewModels/PoweredTemplatePanelViewModel.cs
@@ -0,0 +1,382 @@
+using System.Collections.ObjectModel;
+using System.Windows.Input;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using InterlinedList.Models;
+using InterlinedList.Services;
+
+namespace InterlinedList.ViewModels;
+
+///
+/// Powered Templates: describe a list in plain language, get a proposed schema
+/// plus starter rows back, edit it, then confirm it into a real list.
+///
+/// Two calls, two quota units./suggest previews without writing;
+/// /generate persists. Both count against the 50-a-day allowance, so
+/// every check that can be made locally is made before either leaves — the
+/// 300-word input cap for powered_template, and on confirm: a non-empty
+/// title, at least one column, the documented 20-field ceiling, and unique
+/// legal field keys. Each of those caught locally is a unit not spent learning
+/// it from a 422.
+///
+/// Read-after-write on confirm./generate's envelope is
+/// contract-transcribed, not live-verified (it writes to a shared account), so
+/// its {listId} is treated as a hint: the panel re-fetches the list with
+/// GET /api/lists/{id} and hands the host that, per the repo's
+/// read-after-write rule. If the id isn't there, it still refreshes the host's
+/// list browser and says so rather than claiming a success it can't prove.
+///
+public sealed partial class PoweredTemplatePanelViewModel : AiPanelViewModelBase
+{
+ private AiListDsl? _dsl;
+ private string? _dslName;
+ private string? _dslDescription;
+
+ public PoweredTemplatePanelViewModel(SessionService session, AiAvailabilityService? availability = null)
+ : base(session, availability)
+ {
+ }
+
+ /// Set by the hosting control from its OpenListCommand dependency property.
+ public ICommand? OpenListCommand { get; set; }
+
+ /// Set by the hosting control from its RefreshCommand dependency property.
+ public ICommand? RefreshCommand { get; set; }
+
+ // ── Input ───────────────────────────────────────────────────────────────────
+
+ /// Whether the panel is expanded. Collapsed by default — it's an option, not the default path.
+ [ObservableProperty]
+ private bool isOpen;
+
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(WordCount))]
+ [NotifyPropertyChangedFor(nameof(WordCountLabel))]
+ [NotifyPropertyChangedFor(nameof(IsOverWordCap))]
+ private string description = "";
+
+ public int WordCount => AiFeatureLimits.CountWords(Description);
+
+ public int MaxWords => AiFeatureLimits.For(AiFeature.PoweredTemplate).MaxInputWords;
+
+ public string WordCountLabel => $"{WordCount} / {MaxWords} words";
+
+ ///
+ /// Shown before the button is pressed. The cap is mirrored from the server
+ /// precisely so an over-long description never costs a unit to reject.
+ ///
+ public bool IsOverWordCap => WordCount > MaxWords;
+
+ partial void OnDescriptionChanged(string value) => SuggestCommand.NotifyCanExecuteChanged();
+
+ // ── Preview ─────────────────────────────────────────────────────────────────
+
+ /// True once a suggestion has come back and is waiting to be confirmed or discarded.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(ColumnCountLabel))]
+ private bool hasPreview;
+
+ /// The proposed list title, editable before confirming.
+ [ObservableProperty]
+ private string listTitle = "";
+
+ /// The proposed list description, editable before confirming.
+ [ObservableProperty]
+ private string listDescription = "";
+
+ public ObservableCollection Columns { get; } = new();
+
+ public ObservableCollection Rows { get; } = new();
+
+ /// Label for a new column the user is adding.
+ [ObservableProperty]
+ private string newColumnLabel = "";
+
+ public string ColumnCountLabel => $"{Columns.Count} of {AiListDsl.MaxFields} columns";
+
+ /// What the model charged for the suggestion, for the "powered by" byline.
+ [ObservableProperty]
+ private string? usageLabel;
+
+ // ── Commands ────────────────────────────────────────────────────────────────
+
+ protected override void NotifyAiCommandsChanged()
+ {
+ SuggestCommand.NotifyCanExecuteChanged();
+ ConfirmCommand.NotifyCanExecuteChanged();
+ }
+
+ [RelayCommand]
+ private void ToggleOpen()
+ {
+ IsOpen = !IsOpen;
+ if (IsOpen) _ = EnsureStatusLoadedAsync();
+ }
+
+ private bool CanSuggest() => CanRunAi && !string.IsNullOrWhiteSpace(Description);
+
+ [RelayCommand(CanExecute = nameof(CanSuggest))]
+ private async Task SuggestAsync(CancellationToken ct)
+ {
+ // Pre-flight. A 422 for an over-long input would cost nothing, but the
+ // same check also gives an instant answer instead of a round-trip.
+ if (!AiFeatureLimits.TryValidateInput(AiFeature.PoweredTemplate, Description, out var inputError))
+ {
+ RejectLocally(inputError!);
+ return;
+ }
+
+ var result = await RunAiAsync(
+ "Drafting a list from your description…",
+ token => Session.Api.AiSuggestAsync(AiFeature.PoweredTemplate, Description.Trim(), ct: token),
+ r => r.Quota,
+ ct);
+
+ if (result is null) return;
+
+ if (result.Artifact.AsList() is not { } list)
+ {
+ Notice = new AiNotice(AiNoticeKind.ModelDeclined,
+ $"Expected a list but the AI returned a \"{result.Artifact.Kind}\". " +
+ "That attempt still counted against today's allowance — try describing the list differently.",
+ Spent: true);
+ return;
+ }
+
+ LoadPreview(list);
+ UsageLabel = result.Usage?.Display;
+ Notice = AiNotice.Info("Draft ready. Edit the columns and starter rows below, then create the list.");
+ }
+
+ private void LoadPreview(AiListArtifact list)
+ {
+ _dsl = AiListDsl.FromJson(list.Dsl) ?? AiListDsl.Empty();
+ _dslName = _dsl.Name;
+ _dslDescription = _dsl.Description;
+
+ ListTitle = list.Title;
+ ListDescription = list.Description ?? "";
+
+ // The types this artifact actually uses, so a per-column picker can
+ // offer them without this client inventing DSL types it hasn't seen.
+ var artifactTypes = _dsl.Fields.Select(f => f.Type).ToList();
+
+ Columns.Clear();
+ foreach (var field in _dsl.Fields)
+ Columns.Add(new AiListColumnViewModel(field, artifactTypes));
+
+ Rows.Clear();
+ foreach (var row in list.Rows)
+ Rows.Add(AiListRowViewModel.FromWire(Columns, row));
+
+ HasPreview = true;
+ OnPropertyChanged(nameof(ColumnCountLabel));
+ AddColumnCommand.NotifyCanExecuteChanged();
+ ConfirmCommand.NotifyCanExecuteChanged();
+ }
+
+ [RelayCommand]
+ private void Discard()
+ {
+ HasPreview = false;
+ Columns.Clear();
+ Rows.Clear();
+ ListTitle = "";
+ ListDescription = "";
+ UsageLabel = null;
+ NewColumnLabel = "";
+ _dsl = null;
+ ClearNotice();
+ OnPropertyChanged(nameof(ColumnCountLabel));
+ AddColumnCommand.NotifyCanExecuteChanged();
+ ConfirmCommand.NotifyCanExecuteChanged();
+ }
+
+ [RelayCommand]
+ private void RemoveColumn(AiListColumnViewModel column)
+ {
+ if (column is null || !Columns.Remove(column)) return;
+
+ // Keep the table rectangular: every row loses the matching cell.
+ foreach (var row in Rows)
+ row.RemoveCellFor(column);
+
+ OnPropertyChanged(nameof(ColumnCountLabel));
+ AddColumnCommand.NotifyCanExecuteChanged();
+ ConfirmCommand.NotifyCanExecuteChanged();
+ }
+
+ private bool CanAddColumn() => HasPreview && Columns.Count < AiListDsl.MaxFields;
+
+ [RelayCommand(CanExecute = nameof(CanAddColumn))]
+ private void AddColumn()
+ {
+ var label = NewColumnLabel.Trim();
+ if (label.Length == 0)
+ {
+ RejectLocally("Give the new column a label first.");
+ return;
+ }
+
+ // Legal-key rule mirrored from the DSL contract so an illegal key can't
+ // reach /generate — a rejected /generate costs a unit.
+ var key = AiListDsl.KeyFromLabel(label);
+ if (key is null)
+ {
+ RejectLocally($"\"{label}\" doesn't produce a usable column key — use letters, digits, spaces, - or _.");
+ return;
+ }
+
+ if (Columns.Any(c => c.Key == key))
+ {
+ RejectLocally($"There's already a column with the key \"{key}\".");
+ return;
+ }
+
+ var column = AiListColumnViewModel.NewColumn(key, label, Columns.Select(c => c.Type));
+ Columns.Add(column);
+
+ foreach (var row in Rows)
+ row.AddCellFor(column);
+
+ NewColumnLabel = "";
+ ClearNotice();
+ OnPropertyChanged(nameof(ColumnCountLabel));
+ AddColumnCommand.NotifyCanExecuteChanged();
+ ConfirmCommand.NotifyCanExecuteChanged();
+ }
+
+ [RelayCommand]
+ private void AddRow()
+ {
+ if (Columns.Count == 0) return;
+ Rows.Add(AiListRowViewModel.Blank(Columns));
+ }
+
+ [RelayCommand]
+ private void RemoveRow(AiListRowViewModel row)
+ {
+ if (row is not null) Rows.Remove(row);
+ }
+
+ private bool CanConfirm() => CanRunAi && HasPreview && Columns.Count > 0;
+
+ [RelayCommand(CanExecute = nameof(CanConfirm))]
+ private async Task ConfirmAsync(CancellationToken ct)
+ {
+ if (!TryBuildArtifact(out var artifact, out var error))
+ {
+ RejectLocally(error!);
+ return;
+ }
+
+ var result = await RunAiAsync(
+ "Creating your list…",
+ token => Session.Api.AiGenerateAsync(AiFeature.PoweredTemplate, artifact!, ct: token),
+ r => r.Quota,
+ ct);
+
+ if (result is null) return;
+
+ // The /generate envelope is unverified, so the id is a hint. Prove the
+ // list exists with a fresh GET before telling the host to open it.
+ if (result.Created.ListId is { Length: > 0 } listId)
+ {
+ var created = await RunApiAsync("Loading the new list…", token => Session.Api.GetListAsync(listId, token), ct);
+
+ RefreshCommand?.Execute(null);
+
+ if (created is not null)
+ {
+ Notice = AiNotice.Info($"Created \"{created.Title}\".");
+ Discard();
+ IsOpen = false;
+ OpenListCommand?.Execute(created);
+ return;
+ }
+
+ // Written, but the read-back failed — don't claim more than that.
+ Notice = AiNotice.Info("The list was created, but loading it back failed. It's in the list browser on the left.");
+ Discard();
+ return;
+ }
+
+ // No id came back. Something was probably written, so refresh rather
+ // than assert either outcome.
+ RefreshCommand?.Execute(null);
+ Notice = new AiNotice(AiNoticeKind.Error,
+ "The server accepted the list but didn't return its id. Check the list browser on the left before trying again — a second attempt would create a duplicate and spend another AI credit.");
+ }
+
+ ///
+ /// Build the artifact to persist, running every check that would otherwise
+ /// come back as a 422 from a /generate that already cost a unit.
+ ///
+ private bool TryBuildArtifact(out AiArtifact? artifact, out string? error)
+ {
+ artifact = null;
+ error = null;
+
+ if (string.IsNullOrWhiteSpace(ListTitle))
+ {
+ error = "Give the list a title before creating it.";
+ return false;
+ }
+
+ if (Columns.Count == 0)
+ {
+ error = "A list needs at least one column.";
+ return false;
+ }
+
+ if (Columns.Count > AiListDsl.MaxFields)
+ {
+ error = $"A list schema takes at most {AiListDsl.MaxFields} columns (there are {Columns.Count}).";
+ return false;
+ }
+
+ var blankLabel = Columns.FirstOrDefault(c => string.IsNullOrWhiteSpace(c.Label));
+ if (blankLabel is not null)
+ {
+ error = $"The \"{blankLabel.Key}\" column needs a label.";
+ return false;
+ }
+
+ var duplicate = Columns.GroupBy(c => c.Key, StringComparer.Ordinal).FirstOrDefault(g => g.Count() > 1);
+ if (duplicate is not null)
+ {
+ error = $"Two columns share the key \"{duplicate.Key}\".";
+ return false;
+ }
+
+ var illegal = Columns.FirstOrDefault(c => !AiListDsl.IsValidKey(c.Key));
+ if (illegal is not null)
+ {
+ error = $"The column key \"{illegal.Key}\" isn't valid (lowercase letters, digits, - and _, starting with a letter).";
+ return false;
+ }
+
+ var fields = Columns.Select(c => c.ToField()).ToList();
+ var dsl = (_dsl ?? AiListDsl.Empty()).ToElement(
+ _dslName ?? ListTitle.Trim(),
+ _dslDescription ?? (ListDescription.Length > 0 ? ListDescription.Trim() : null),
+ fields);
+
+ // An all-blank row is noise; the server drops rows that fail schema
+ // validation anyway, so don't send them in the first place.
+ var rows = Rows
+ .Where(r => !r.IsEmpty)
+ .Select(r => r.ToWire(Columns))
+ .ToList();
+
+ artifact = AiArtifact.FromPayload(new AiListArtifactPayload
+ {
+ Title = ListTitle.Trim(),
+ Description = ListDescription.Length > 0 ? ListDescription.Trim() : null,
+ Dsl = dsl,
+ Rows = rows
+ });
+
+ return true;
+ }
+}
diff --git a/InterlinedList/Views/ListsView.xaml b/InterlinedList/Views/ListsView.xaml
index 8888574..805a8d6 100644
--- a/InterlinedList/Views/ListsView.xaml
+++ b/InterlinedList/Views/ListsView.xaml
@@ -680,6 +680,8 @@
HorizontalScrollBarVisibility="Disabled"
Padding="20,12">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/InterlinedList/Views/PoweredTemplatePanel.xaml.cs b/InterlinedList/Views/PoweredTemplatePanel.xaml.cs
new file mode 100644
index 0000000..02e6274
--- /dev/null
+++ b/InterlinedList/Views/PoweredTemplatePanel.xaml.cs
@@ -0,0 +1,80 @@
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using InterlinedList.Services;
+using InterlinedList.ViewModels;
+
+namespace InterlinedList.Views;
+
+///
+/// The Powered Templates surface: the web app's "Powered Templates tab in the
+/// new-list flow", as a self-contained card.
+///
+/// Hosting it costs one XAML line and no code-behind:
+///
+/// <local:PoweredTemplatePanel OpenListCommand="{Binding SelectListCommand}"
+/// RefreshCommand="{Binding LoadListsCommand}"/>
+///
+///
+/// That shape is deliberate. Views/ListsView.xaml is contended by
+/// several other open PRs (#161/#163/#164), so this follows the pattern that
+/// worked on #55/#56: everything lives in the control and its own ViewModel,
+/// and the host contributes a single line it can move anywhere. The control
+/// news up its own ViewModel from rather than having
+/// one injected, matching every other view in this folder.
+///
+/// It hides itself entirely when the AI gate is closed (free account, or a
+/// server with no Anthropic key), so the host needs no visibility rule either.
+///
+public partial class PoweredTemplatePanel : UserControl
+{
+ ///
+ /// Invoked with the freshly re-fetched
+ /// after a confirmed generation, so the host can select the new list. Bind
+ /// it to the host's existing "select this list" command.
+ ///
+ public static readonly DependencyProperty OpenListCommandProperty =
+ DependencyProperty.Register(
+ nameof(OpenListCommand), typeof(ICommand), typeof(PoweredTemplatePanel),
+ new PropertyMetadata(null, OnOpenListCommandChanged));
+
+ ///
+ /// Invoked with null after a confirmed generation so the host reloads its
+ /// list browser. Bind it to the host's existing reload command.
+ ///
+ public static readonly DependencyProperty RefreshCommandProperty =
+ DependencyProperty.Register(
+ nameof(RefreshCommand), typeof(ICommand), typeof(PoweredTemplatePanel),
+ new PropertyMetadata(null, OnRefreshCommandChanged));
+
+ private readonly PoweredTemplatePanelViewModel _vm;
+
+ public PoweredTemplatePanel()
+ {
+ InitializeComponent();
+ _vm = new PoweredTemplatePanelViewModel(AppServices.Session);
+ DataContext = _vm;
+
+ // Idempotent and single-flighted on the shared service, so every AI
+ // panel can do this without a thundering herd of status GETs.
+ _ = _vm.EnsureStatusLoadedAsync();
+ }
+
+ public ICommand? OpenListCommand
+ {
+ get => (ICommand?)GetValue(OpenListCommandProperty);
+ set => SetValue(OpenListCommandProperty, value);
+ }
+
+ public ICommand? RefreshCommand
+ {
+ get => (ICommand?)GetValue(RefreshCommandProperty);
+ set => SetValue(RefreshCommandProperty, value);
+ }
+
+ private static void OnOpenListCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((PoweredTemplatePanel)d)._vm.OpenListCommand = e.NewValue as ICommand;
+
+ private static void OnRefreshCommandChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ => ((PoweredTemplatePanel)d)._vm.RefreshCommand = e.NewValue as ICommand;
+}