diff --git a/README.md b/README.md index 1d4d129..6eac9be 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,10 @@ quota preservation and Code-owned upstream fallback repair are not planned. ## Native workflows - **Code workbench:** one main view under a top bar of tabs (Generator, Accounts, - Sessions) and one **More** menu (Models, Setup, Options, Shortcuts); the panel - reads its inputs again on its own. The **generator** is five rows of plain + Sessions) and one **More** menu (Models, Setup, Options, Shortcuts, Tutorial); the panel + reads its inputs again on its own. A person's first open offers a short tutorial + that points at the real controls one at a time, once per person and replayable + from More. The **generator** is five rows of plain words (lane, model, thinking, advisor, fallbacks) changed in place by pointer, keys or wheel, each model shown by its short alias per tier, with **Defaults** and **Revert** in its head and a readout line that says what a pointed word does, diff --git a/docs/configuration.md b/docs/configuration.md index 1e0ce0d..61273f8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -50,6 +50,7 @@ All Code actions are `atyrode.code.`: | Configuration | `readConfiguration`, `initializeConfiguration`, `select`, `changeAccounts` | | Catalog authoring | `stageCatalog`, `reviewCatalog`, `promoteCatalog`, `discardCatalog` | | A person's checks | `readChecks`, `approveChecks`, `adoptCheck` | +| A person's tutorial | `readTutorial`, `seeTutorial` | | Pure OMP input policy | `composeProbe`, `draftInventory`, `deriveCatalog`, `composeSession` | | External classifier | `readServiceConfiguration`, `reviewServices`, `configureServices`, `suggest` | @@ -94,6 +95,15 @@ provider the pool does not include, and `code_check_current` when the workspace holds a list of that pool whose benchmark completed no earlier. It saves `selection` (else the saved one) narrowed to the check's list, and leaves a staged list as it is. +Whether a person has seen the panel's tutorial is their own record too, kept the same +way at `tutorial/`. `readTutorial({})` answers `{ keeps, seenAt }`: whether the +caller keeps the record at all, and when they first finished, skipped or answered the +tutorial's offer (null before). `seeTutorial({})` records it once and answers the same: +a record already there stands as it is, so two tabs or devices marking it agree. Neither +refuses a caller that keeps none: an agent, a service or a confined sign-in reads and +marks `keeps: false`, and the panel then keeps it in the browser instead. Both ask no +container capability and are container-graded, as the checks' doors are. + `CatalogDocumentSchema` is `{ schemaVersion: 1, models: [...] }`. Each model has an explicit key, provider/id/API identity, tier, quota bucket, costs, performance, context, thinking levels and image support. Capabilities are 1–4. Tier 0 @@ -287,7 +297,7 @@ empty prompt. Headless callers still pass a prompt. ## Workbench presentation The main view is one panel. A **top bar** carries the views as tabs (Generator, -Accounts, Sessions) and one **More** menu (Models, Setup, Options, Shortcuts). The +Accounts, Sessions) and one **More** menu (Models, Setup, Options, Shortcuts, Tutorial). The panel reads its inputs again on its own, so it has no Refresh button. The **generator** holds five rows of plain words, a readout line, the cost and speed meters and the launch row; **routing** stands beside it and @@ -325,8 +335,9 @@ one, More reads **More · staged** and its item **Models · staged**, since noth until it is reviewed there. When session options are set, More and its **Options** item carry their summary ("· restricted", "· skills off", "· 2 skills"). **Shortcuts** opens a native modal dialog that lists every key under the view it acts in (Generator, Views, -Accounts, Sessions, Anywhere), with `m`, `u` and `o` under Generator, where they act; -`Esc` or its Close button closes it and it owns its keys while open. +Accounts, Sessions, Tutorial, Anywhere), with `m`, `u` and `o` under Generator, where they act; +`Esc` or its Close button closes it and it owns its keys while open. **Tutorial** walks the +tutorial again (below). The workbench reads its inputs again on its own: the workspace profile, OMP's setup, defaults, bundled model list, skills and accounts, and the machine list, once a minute @@ -346,6 +357,35 @@ cadence (below). Both cadences are one schedule: reads that come due together sh pass that reads each observation once, and **Refresh now** (`r`) or opening or closing a sheet reads everything in one pass and restarts both. +### The tutorial + +On a person's first open of the panel, wherever they open it, a question asks "Welcome +to Code" with **Take the tour** and **Skip** (`Esc` and the backdrop skip), once the +generator shows its profile in the main view with no sheet open, nothing running, no +charge waiting and no other question open. It is offered to people only: an agent or a +service is never asked. Either answer marks the tutorial seen for that person +(`seeTutorial`, or the browser for a sign-in that keeps no record), so it is never offered +again; **Tutorial** in More walks it whenever they like. The walk starts on the generator +(a sheet closes and the view goes back to it) and keeps to the steps the panel shows +there, so its count holds when a step opens another view: the profile's rows, the +readout, Defaults and Revert, routing, usage, the machine, the launch, the Accounts and +Sessions tabs, and More. Each step draws a thin ring in the accent around its control and +a card beside it (under it where the panel shows room, else over it), both laid over the +panel so nothing in it moves, and both kept to the part of the panel the window shows: the +step's name, where the walk stands ("3 of 10"), one sentence, and **Next** (**Done** at +the last), **Back** and **Skip**; `→` and `←` step, `Esc` skips, and a held key steps once. +Each step's card is its own, so moving to it moves focus to its Next and it is read out, +and its control is scrolled into view. Routing and usage are pointed at where the layout +puts them: beside the generator, else their tab or Show control. A step may be tried: +choosing a word in the rows, or opening Accounts, goes on by itself once done since the +step began, and Next always works too; Back onto a step another view hides brings the +generator back. The walk launches nothing and spends nothing; a word tried changes the +rows as any choice does, which Revert puts back. A step whose control is not shown (another +view or a sheet has the panel), or any step while a popover of the panel is open (More's +menu, the machine list), hides its card until that changes. The walk's end, finished or +skipped, puts focus on More, where it is replayed, so a second `↵` of a double press opens +a menu rather than launching. + ### The generator Rows run top to bottom: **lead**, **model**, **thinking**, **advisor** and diff --git a/plugins/code/checks.ts b/plugins/code/checks.ts index 2463a97..d6bc928 100644 --- a/plugins/code/checks.ts +++ b/plugins/code/checks.ts @@ -6,7 +6,8 @@ import { CatalogDocumentSchema, DomainError, SelectionSchema, type CatalogDocume import { clampSelection } from "../domain/routing.ts"; import { ChecksSchema, MAX_CHECKS, VerificationProvenanceSchema, digest, id, revision, type ActionInput, type Checks, type Configuration, type ModelCheck, type VerificationProvenance } from "./contract.ts"; -import { CodeRefusal, digestOf, type CodeContext } from "./context.ts"; +import { CodeRefusal, type CodeContext } from "./context.ts"; +import { bound, keeperOf, personOf } from "./person.ts"; import { commitConfiguration, expectRevision, poolIdentity, readConfiguration, requireConfiguration } from "./state.ts"; /* @@ -15,7 +16,7 @@ import { commitConfiguration, expectRevision, poolIdentity, readConfiguration, r also kept for the person who checked it, under the exact pool it was verified with and the budget it was derived under, and any workspace whose saved choices select that pool adopts it instead of spending another check. The person's standing approval of what checks spend lives beside them. - Both are keyed by the caller's own principal: nobody reads or writes another person's, and an + Both are the person's own records (person.ts): nobody reads or writes another person's, and an agent, a service or a token confined to one container keeps none. Workspaces keep their own copies, so a `runSession` caller still reads a workspace. */ @@ -33,33 +34,9 @@ const StoredApprovalSchema = z.strictObject({ /** Plugin storage holds at most this many bytes per value (plugin-kit `MAX_STORAGE_VALUE_BYTES`). */ const MAX_VALUE_BYTES = 64 << 10; -/** Why a caller keeps no checks: not a person, or a person whose sign-in reaches less than the whole workspace tree. */ -type Keeper = { person: string } | { refused: "person_required" | "checks_confined" }; -/** - * The caller's records are filed under a digest of their principal, cut to 40 hex characters so a - * check's key (`checks///`) stays within the 128 characters plugin storage - * keys may have; every record also names its principal, which every read compares. Only a person - * whose sign-in reaches the workspace root keeps checks, asked where an unconfined credential is - * anchored: a token confined to one container, or by an authority ceiling, keeps none, since a - * check and the approval to spend on checks are the person's own, never one container's. - */ -async function keeperOf(ctx: CodeContext): Promise { - if (ctx.auth.principal.kind !== "human") return { refused: "person_required" }; - if (ctx.auth.containerScope !== null || !(await ctx.auth.allows("containers:read"))) return { refused: "checks_confined" }; - return { person: digestOf({ principalId: ctx.auth.principal.id }).slice(0, 40) }; -} -async function personOf(ctx: CodeContext): Promise { - const keeper = await keeperOf(ctx); - return "person" in keeper ? keeper.person : null; -} const checkKey = (person: string, budget: Budget, pool: string) => `checks/${person}/${budget}/${pool}`; const approvalKey = (person: string) => `approvals/${person}`; -/** A stored record that names another principal, or another key than its own, is corruption, never someone's data. */ -function bound(ctx: CodeContext, record: T): T { - if (record.principalId !== ctx.auth.principal.id) throw new CodeRefusal("invalid_configuration"); - return record; -} async function readApproval(ctx: CodeContext, person: string) { const raw = await ctx.storage.get(approvalKey(person)); return raw === null ? null : { raw, approval: bound(ctx, StoredApprovalSchema.parse(JSON.parse(raw))) }; diff --git a/plugins/code/contract.ts b/plugins/code/contract.ts index ce3487f..a5d98b6 100644 --- a/plugins/code/contract.ts +++ b/plugins/code/contract.ts @@ -114,6 +114,13 @@ export const ChecksSchema = z.strictObject({ checks: z.array(ModelCheckSchema).max(MAX_CHECKS), }); export type Checks = z.infer; +/** + * WHETHER A PERSON HAS SEEN THE PANEL'S TUTORIAL. `keeps` says whether the caller keeps the record + * at all (false for an agent, a service or a confined sign-in, whose browser keeps it instead); + * `seenAt` is when they first answered its offer or ended a walk, null before. + */ +export const TutorialSchema = z.strictObject({ keeps: z.boolean(), seenAt: epochMilliseconds.nullable() }); +export type Tutorial = z.infer; /** The prompt bound is OMP's, in bytes, because the prompt reaches the machine as one entry * of the job input map the hub bounds (`PROMPT_MAX_BYTES`, 44 KiB). Code takes the schema * itself rather than the number, so the two can never disagree. */ @@ -254,6 +261,9 @@ export const rootActionSchemas = { readChecks: { input: z.strictObject({}), result: ChecksSchema }, // `requests: 0` withdraws the approval, so every later check asks again. approveChecks: { input: z.strictObject({ expectedRevision: revision, requests: ChecksSchema.shape.approvedRequests }), result: ChecksSchema }, + // The caller's own tutorial record: read it, or record that they have seen it (idempotent). + readTutorial: { input: z.strictObject({}), result: TutorialSchema }, + seeTutorial: { input: z.strictObject({}), result: TutorialSchema }, // Puts the caller's check of the workspace's present pool, derived under `budget`, in use, with `selection` (else the saved // one) narrowed to it. `poolIdentityDigest` is the pool the caller found a check for; Code recomputes the pool and refuses any other. adoptCheck: { input: RevisionWorkspaceSchema.extend({ accounts: AccountsObservationSchema, poolIdentityDigest: digest, diff --git a/plugins/code/generator/decision-model.ts b/plugins/code/generator/decision-model.ts index c115a96..e1fdd96 100644 --- a/plugins/code/generator/decision-model.ts +++ b/plugins/code/generator/decision-model.ts @@ -3,23 +3,25 @@ import type { RefusalView, StatusAction, Vocabulary } from "./statement-model.ts import type { VerificationMode } from "./verification.ts"; /* - * WHAT A PRESS WAITS ON THE PERSON FOR, as one question: a model check's charge to approve, a - * reviewed session that differs from what the panel showed, a check that changed the profile, or a - * refused Launch and its fixes. Pure, so which question is asked, which choices it offers, which is - * the way out and what answering does are tested apart from the dialog that asks it - * (decision-dialog.tsx). Only a press the person made asks: a check the panel runs on its own never does. + * WHAT THE PANEL WAITS ON THE PERSON FOR, as one question: a model check's charge to approve, a + * reviewed session that differs from what the panel showed, a check that changed the profile, a + * refused Launch and its fixes, or, on a person's first open, whether to take the tutorial. Pure, so + * which question is asked, which choices it offers, which is the way out and what answering does are + * tested apart from the dialog that asks it (decision-dialog.tsx). Only a press the person made asks + * about a check: a check the panel runs on its own never does. */ -/** What a choice does: approve or cancel the waiting charge, take the launch's own press, leave the question, or run a fix. */ +/** What a choice does: approve or cancel the waiting charge, take the launch's own press, start the tutorial, leave the question, or run a fix. */ export type DecisionDo = | { readonly kind: "approve" } | { readonly kind: "cancel" } | { readonly kind: "launch" } + | { readonly kind: "tour" } | { readonly kind: "dismiss" } | { readonly kind: "fix"; readonly action: Extract }; export type DecisionChoice = { readonly label: string; readonly does: DecisionDo }; export type Decision = { - readonly kind: "charge" | "differs" | "narrowed" | "refused"; + readonly kind: "charge" | "differs" | "narrowed" | "refused" | "tutorial"; readonly title: string; /** What is asked and why, a plain sentence each. */ readonly text: readonly string[]; @@ -28,6 +30,8 @@ export type Decision = { /** The choices in reading order. The first is the next action; the last is the way out, which Esc and the backdrop take, and which never spends or launches. */ readonly choices: readonly DecisionChoice[]; }; +/** The questions a press waits on, which `decisionFor` asks and `answer` answers; the tutorial's offer is the workbench's own. */ +export type PressDecision = Decision & { readonly kind: Exclude }; /** A check's charge as it waits: the requests in all and per provider. */ export type ChargeFacts = { readonly requests: number; readonly providers: readonly { readonly provider: string; readonly requests: number }[] }; @@ -53,7 +57,7 @@ export type DecisionFacts = { * struck lane, never from a check the panel runs on its own), a review that differs, a check that * changed the profile, then a refused press; null when nothing waits on the person. */ -export function decisionFor(facts: DecisionFacts, vocab: Vocabulary): Decision | null { +export function decisionFor(facts: DecisionFacts, vocab: Vocabulary): PressDecision | null { if (facts.charge && facts.mode !== null && facts.mode !== "background") return chargeDecision(facts.charge, facts.mode, facts.keepsChecks, vocab); if (facts.differs && facts.ready) return differsDecision(facts.differs); if (facts.narrowed && facts.ready) return narrowedDecision(facts.narrowed); @@ -75,7 +79,7 @@ export type DecisionEffect = * only with the launch's press, and Not now drops it, so the next press reviews again. A narrowed * stop and a refused press end their question, then run what was chosen. */ -export function answer(decision: Decision, choice: DecisionChoice): DecisionEffect[] { +export function answer(decision: PressDecision, choice: DecisionChoice): DecisionEffect[] { const does = choice.does; const then: DecisionEffect[] = does.kind === "launch" ? [{ kind: "launch" }] : does.kind === "fix" ? [{ kind: "fix", action: does.action }] : []; switch (decision.kind) { @@ -93,7 +97,7 @@ const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? * once lets later checks run without asking (only for a person who keeps checks). A charge of nothing * asks nothing: it says so, offers the accounts, and its way out cancels the check. */ -function chargeDecision(charge: ChargeFacts, mode: Exclude, keepsChecks: boolean, vocab: Vocabulary): Decision { +function chargeDecision(charge: ChargeFacts, mode: Exclude, keepsChecks: boolean, vocab: Vocabulary): PressDecision { if (charge.requests === 0) { return { kind: "charge", title: "Nothing to check", text: ["None of the models your included accounts offer can be checked."], items: [], choices: [{ label: "Show accounts", does: { kind: "fix", action: { kind: "fix", key: "accounts", label: "Show accounts", fix: { kind: "open", place: "accounts" } } } }, @@ -118,19 +122,25 @@ function chargeDecision(charge: ChargeFacts, mode: Exclude ({ label: action.label, does: { kind: "fix", action } }) as const), { label: "Close", does: { kind: "dismiss" } }] }; } + +/** A person's first open: what Code is, and whether to take the short tutorial now. Skip is the way out; More replays it. */ +export function tutorialOffer(): Decision { + return { kind: "tutorial", title: "Welcome to Code", text: ["Code starts OMP coding sessions with the models you choose. A short tour shows where everything is."], + items: [], choices: [{ label: "Take the tour", does: { kind: "tour" } }, { label: "Skip", does: { kind: "dismiss" } }] }; +} diff --git a/plugins/code/generator/more-menu.tsx b/plugins/code/generator/more-menu.tsx index cd4a264..ec729f5 100644 --- a/plugins/code/generator/more-menu.tsx +++ b/plugins/code/generator/more-menu.tsx @@ -21,8 +21,8 @@ export type MenuCommand = { }; /** - * The bar's one menu: a button, More, that opens a short menu of the panel's sheets and the - * shortcuts, the WAI-ARIA menu button. A press, ↓, ↵ or Space opens it on its first item, ↑ on its + * The bar's one menu: a button, More, that opens a short menu of the panel's sheets, the shortcuts + * and the tutorial, the WAI-ARIA menu button. A press, ↓, ↵ or Space opens it on its first item, ↑ on its * last; ↑↓, Home and End move, ↵ or Space chooses, and an item's own key chooses it too. Esc closes * it and returns focus to More; Tab, a press outside or focus leaving closes it. While it is open * it owns its keys (`data-popover`), so the panel's keys leave them alone. Choosing returns focus to diff --git a/plugins/code/generator/styles.css b/plugins/code/generator/styles.css index 54c2f91..871d075 100644 --- a/plugins/code/generator/styles.css +++ b/plugins/code/generator/styles.css @@ -754,6 +754,29 @@ @media (max-width: 300px) { .plugin-atyrode_code_generator .plugin-atyrode_code_generator__decision-body { padding: 14px 12px 12px; } } +/* + * The tutorial (tutorial.tsx): a thin ring in the accent around the real control, and a card beside + * it in the dialog's grammar, a rule in the accent around it so it reads as part of the walk. Both + * are laid over the panel (absolute in its root), so nothing in the panel moves; the ring takes no + * pointer. The card waits unseen until it is placed, so it never shows where it does not belong. + */ +.plugin-atyrode_code_generator .plugin-atyrode_code_generator__tutorial-ring { position: absolute; z-index: 30; box-sizing: border-box; border: 1px solid var(--tui-acc); pointer-events: none; } +.plugin-atyrode_code_generator .plugin-atyrode_code_generator__tutorial { position: absolute; z-index: 31; box-sizing: border-box; max-height: calc(100% - 16px); overflow-y: auto; padding: 12px 14px 10px; border: 1px solid var(--tui-acc); color: var(--tui-fg); background: var(--code-panel); font: 14px/20px var(--code-sans); } +.plugin-atyrode_code_generator .plugin-atyrode_code_generator__tutorial:not([data-placed]) { top: 0; left: 0; visibility: hidden; } +.plugin-atyrode_code_generator .plugin-atyrode_code_generator__tutorial-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 0 12px; } +.plugin-atyrode_code_generator .plugin-atyrode_code_generator__tutorial h2 { margin: 0; color: var(--tui-acc); font-size: 15px; font-weight: 650; line-height: 22px; overflow-wrap: anywhere; } +.plugin-atyrode_code_generator .plugin-atyrode_code_generator__tutorial-count { color: var(--tui-dim); font-size: 13px; white-space: nowrap; } +.plugin-atyrode_code_generator .plugin-atyrode_code_generator__tutorial p { margin: 4px 0 0; color: var(--tui-mid); overflow-wrap: anywhere; } +.plugin-atyrode_code_generator .plugin-atyrode_code_generator__tutorial-choices { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 18px; margin-top: 10px; } +.plugin-atyrode_code_generator .plugin-atyrode_code_generator__tutorial-choices button { white-space: normal; overflow-wrap: anywhere; } +.plugin-atyrode_code_generator .plugin-atyrode_code_generator__tutorial-skip { margin-left: auto; color: var(--tui-dim); } +@media (pointer: coarse) { + .plugin-atyrode_code_generator .plugin-atyrode_code_generator__tutorial-choices button { min-width: 44px; min-height: 44px; } +} +/* The card stands outside the stage and the sheets, whose reduced-motion rules stop their fades, so it stops its own. */ +@media (prefers-reduced-motion: reduce) { + .plugin-atyrode_code_generator .plugin-atyrode_code_generator__tutorial *, .plugin-atyrode_code_generator .plugin-atyrode_code_generator__tutorial-ring { transition-duration: 0s !important; transition-delay: 0s !important; animation-duration: 0s !important; animation-delay: 0s !important; } +} /* Models: the list as the ladders it is, wide as a matrix (tiers down, providers across), else one ladder per provider. */ .plugin-atyrode_code_generator .plugin-atyrode_code_generator__models-matrix { display: grid; grid-template-columns: max-content repeat(var(--cols), minmax(0, 1fr)); column-gap: 22px; min-width: 0; } diff --git a/plugins/code/generator/tutorial-model.ts b/plugins/code/generator/tutorial-model.ts new file mode 100644 index 0000000..0ddfe2f --- /dev/null +++ b/plugins/code/generator/tutorial-model.ts @@ -0,0 +1,57 @@ +/* + * THE TUTORIAL: a short walk through the panel, one real control at a time, offered once per person + * and replayed from More. Each step names what it points at with its own selectors, in the order the + * layout may show it (a pane beside the generator, else its tab, else its Show control), so the same + * walk serves every width. A step whose control is not shown is passed over, and the count says how + * many the walk has now. Pure, so the walk's order and bounds are tested apart from the card that + * draws it (tutorial.tsx). + */ + +/** Generator-panel class prefix; the selectors below name parts of the panel by it. */ +const G = "plugin-atyrode_code_generator__"; + +/** Something the person may try at a step, which takes the walk on once done: choosing a word, or opening the accounts. */ +export type TutorialTry = "word" | "accounts"; +export type TutorialStep = { + readonly id: string; + readonly title: string; + readonly text: string; + /** Where the step points: the first of these the panel shows. */ + readonly targets: readonly string[]; + readonly tries?: TutorialTry; +}; + +export const TUTORIAL_STEPS: readonly TutorialStep[] = [ + { id: "rows", title: "Your profile", text: "These words choose which models do the work and how hard they think. Click one to try it.", + targets: [`[data-pane="generator"] .${G}dials`], tries: "word" }, + { id: "readout", title: "What it would do", text: "This line says what anything you point at would change, quota included.", + targets: [`[data-pane="generator"] .${G}readout`] }, + { id: "defaults", title: "Defaults and Revert", text: "Defaults gives the standard profile. After a change, Revert brings back the saved one.", + targets: [`[data-pane="generator"] [data-defaults]`] }, + { id: "routing", title: "Routing", text: "Each role in a session and the model it gets.", + targets: [`[data-pane="routing"]`, `[data-view-tab="routing"]`, `[data-reveal][aria-keyshortcuts="p"]`] }, + { id: "usage", title: "Usage", text: "How much of each account's quota is left, over 5 hours and over a week.", + targets: [`[data-pane="usage"]`, `[data-view-tab="usage"]`, `[data-reveal][aria-keyshortcuts="s"]`] }, + { id: "machine", title: "Machine", text: "Where the session runs.", targets: ["[data-machine-picker]"] }, + { id: "launch", title: "Launch", text: "Opens a terminal on that machine with OMP running; you type your task there. The first launch checks your models once, after asking you.", + targets: [`[data-pane="generator"] button.${G}launch`] }, + { id: "accounts", title: "Accounts", text: "Which of your accounts this workspace may use. Open it to see.", targets: [`[data-view-tab="accounts"]`], tries: "accounts" }, + { id: "sessions", title: "Sessions", text: "What is running, with live dials, and saved sessions to reopen.", targets: [`[data-view-tab="sessions"]`] }, + { id: "more", title: "More", text: "Models, Setup, Options and Shortcuts. Replay this tutorial here any time.", targets: ["[data-more]"] }, +]; + +/** Where the walk stands among the steps shown now: 1-based, and how many there are; null when the step is not shown. */ +export function tutorialPlace(shown: readonly boolean[], step: number): { readonly position: number; readonly count: number } | null { + if (!shown[step]) return null; + return { position: shown.slice(0, step + 1).filter(Boolean).length, count: shown.filter(Boolean).length }; +} +/** The next step shown after `step` (from the start for -1); null past the last, which ends the walk. */ +export function nextTutorialStep(shown: readonly boolean[], step: number): number | null { + for (let next = step + 1; next < shown.length; next++) if (shown[next]) return next; + return null; +} +/** The step shown before `step`; null at the first. */ +export function previousTutorialStep(shown: readonly boolean[], step: number): number | null { + for (let previous = step - 1; previous >= 0; previous--) if (shown[previous]) return previous; + return null; +} diff --git a/plugins/code/generator/tutorial.tsx b/plugins/code/generator/tutorial.tsx new file mode 100644 index 0000000..abd497b --- /dev/null +++ b/plugins/code/generator/tutorial.tsx @@ -0,0 +1,203 @@ +import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from "react"; +import { createPortal } from "react-dom"; +import type { HostServices } from "@manifold/plugin"; +import { codeWorkflow } from "../machine-web.ts"; +import { nextTutorialStep, previousTutorialStep, TUTORIAL_STEPS, tutorialPlace, type TutorialStep } from "./tutorial-model.ts"; + +/** Generator-panel class prefix; every part hangs from the generator root (styles.css). */ +const G = "plugin-atyrode_code_generator__"; +/** The card's room: its widest, its distance from what it points at and from the panel's edges, and the ring's margin around the control. */ +const CARD_MAX_PX = 320, GAP_PX = 10, EDGE_PX = 8, RING_PX = 4; + +/** + * Whether the person has seen the tutorial: their own record, read once, else, for a sign-in that + * keeps none (tutorial.ts), this browser's; null until read, and while it cannot be read, when the + * tutorial is not offered. `see` records it, at once on screen; a record that fails to write only + * means the tutorial is offered again next time, so nothing waits on it. Seen before the record is + * read (a walk replayed from More and ended at once), it is written once the read says where. + */ +export function useTutorialSeen(host: HostServices): { readonly seen: boolean | null; readonly see: () => void } { + const [seen, setSeen] = useState(null); + const keeps = useRef(null); + const early = useRef(false); + const local = `atyrode.code.tutorial:${host.principal.id}`; + function write(kept: boolean) { + if (!kept) { rememberInBrowser(local); return; } + codeWorkflow(host).seeTutorial().catch(() => { /* Offered again next time, as above. */ }); + } + useEffect(() => { + let live = true; + setSeen(null); + keeps.current = null; + early.current = false; + codeWorkflow(host).readTutorial().then(record => { + if (!live) return; + keeps.current = record.keeps; + if (early.current) { write(record.keeps); setSeen(true); return; } + setSeen(record.keeps ? record.seenAt !== null : browserSeen(local)); + }, () => { /* Unread: not offered, as above. */ }); + return () => { live = false; }; + }, [host.principal.id]); + function see() { + setSeen(true); + if (keeps.current === null) early.current = true; + else write(keeps.current); + } + return { seen, see }; +} +/** A browser that cannot keep anything has not seen it; one that throws on access neither. */ +function browserSeen(key: string): boolean { + try { + const store = globalThis.localStorage; + return store ? store.getItem(key) !== null : false; + } catch { return false; } +} +function rememberInBrowser(key: string) { + try { globalThis.localStorage?.setItem(key, String(Date.now())); } catch { /* A browser that keeps nothing offers it again. */ } +} + +/** The control a step points at in the panel: the first of its targets that is shown, or null. */ +export function tutorialTarget(panel: HTMLElement, step: TutorialStep): HTMLElement | null { + for (const selector of step.targets) { + const found = panel.querySelector(selector); + if (found && found.getClientRects().length > 0) return found; + } + return null; +} +/** Which steps the panel shows a control for now. */ +export function tutorialShown(panel: HTMLElement): boolean[] { + return TUTORIAL_STEPS.map(step => tutorialTarget(panel, step) !== null); +} + +type Box = { readonly top: number; readonly left: number; readonly width: number; readonly height: number }; +type Layout = { readonly card: Box | null; readonly ring: Box | null }; +const HIDDEN: Layout = { card: null, ring: null }; + +/** + * One step of the tutorial: a thin ring in the accent around the real control and a card beside it, + * under it where the panel shows room, else over it, both laid over the panel without moving anything + * in it and both kept to the part of the panel the window shows. The card says what the control is, + * where the walk stands among the steps it keeps to (`shown`, "3 of 10"), and offers Next (Done at + * the last), Back and Skip; `→` and `←` step too, and `Esc` skips; a held key steps once. Each step's + * card is a card of its own, so moving to it moves focus to its Next and it is read out, and its + * control is scrolled into view. A step whose control is not shown now (another view or a sheet has + * the panel), and any step while a popover of the panel is open (More's menu, the machine list), hides + * the card and the ring until that changes. Placed in the panel's own coordinates, so a panel in a + * translated canvas places it as well as one that is not. + */ +export function TutorialCard({ panel, step, shown, accent, onNext, onBack, onEnd }: { + panel: HTMLElement; step: number; shown: readonly boolean[]; accent: string; onNext: () => void; onBack: () => void; onEnd: () => void; +}) { + const id = useId(); + const card = useRef(null); + const next = useRef(null); + const [layout, setLayout] = useState(HIDDEN); + const current = TUTORIAL_STEPS[step]!; + + function measure() { + const target = tutorialTarget(panel, current); + let placed = HIDDEN; + if (target && !panel.querySelector("[data-popover]")) { + const room = panel.getBoundingClientRect(), at = target.getBoundingClientRect(); + // The part of the panel the window shows, in the panel's coordinates. + const view = { top: Math.max(0, -room.top), bottom: Math.min(room.height, innerHeight - room.top) }; + const width = Math.max(0, Math.min(CARD_MAX_PX, room.width - 2 * EDGE_PX)); + const height = card.current?.offsetHeight ?? 0; + const top = at.top - room.top, bottom = at.bottom - room.top; + const under = bottom + GAP_PX, over = top - GAP_PX - height; + const highest = view.top + EDGE_PX, lowest = Math.max(highest, view.bottom - EDGE_PX - height); + const cardTop = Math.min(Math.max(under + height <= view.bottom - EDGE_PX ? under : over >= highest ? over : lowest, highest), lowest); + const left = Math.min(Math.max(at.left - room.left, EDGE_PX), room.width - EDGE_PX - width); + // The ring around the control, within what the panel shows of it; none when nothing of it shows. + const ringTop = Math.max(view.top + 1, top - RING_PX), ringBottom = Math.min(view.bottom - 1, bottom + RING_PX); + const ringLeft = Math.max(1, at.left - room.left - RING_PX), ringRight = Math.min(room.width - 1, at.right - room.left + RING_PX); + placed = { card: { top: cardTop, left, width, height }, + ring: ringBottom > ringTop && ringRight > ringLeft ? { top: ringTop, left: ringLeft, width: ringRight - ringLeft, height: ringBottom - ringTop } : null }; + } + setLayout(previous => JSON.stringify(previous) === JSON.stringify(placed) ? previous : placed); + } + // Every render may have moved the control (a read landing, a pane growing); the card follows it before paint. + useLayoutEffect(measure); + const latest = useRef(measure); + latest.current = measure; + // Between renders it follows what moves on its own: a resize of the panel, the stage or the control, a scroll, a popover + // opening or closing, a view or pane shown or hidden, and the end of any motion under way in the panel (a pane sliding in + // moves its control without resizing or changing anything an observer sees); at most once a frame. + useEffect(() => { + const view = panel.ownerDocument.defaultView; + const watched = new WeakSet(); + let frame = 0, live = true; + const follow = () => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + latest.current(); + for (const motion of panel.getAnimations({ subtree: true })) { + if (watched.has(motion)) continue; + watched.add(motion); + motion.finished.then(() => { if (live) follow(); }, () => {}); + } + }); + }; + const sizes = new ResizeObserver(follow), changes = new MutationObserver(follow); + sizes.observe(panel); + const stage = panel.querySelector("[data-tui]"), target = tutorialTarget(panel, current); + if (stage) sizes.observe(stage); + if (target) sizes.observe(target); + changes.observe(panel, { subtree: true, childList: true, attributes: true, attributeFilter: ["data-popover", "hidden"] }); + panel.addEventListener("scroll", follow, true); + view?.addEventListener("resize", follow); + follow(); + return () => { + live = false; + cancelAnimationFrame(frame); + sizes.disconnect(); changes.disconnect(); + panel.removeEventListener("scroll", follow, true); + view?.removeEventListener("resize", follow); + }; + }, [panel, step]); + // A step brings its control into view and takes focus on Next once its card shows, once per step: a card hidden while + // another view has the panel and shown again takes nothing from what the person is doing. + const showing = layout.card !== null; + const focused = useRef(null); + useLayoutEffect(() => { + if (!showing || focused.current === step) return; + focused.current = step; + tutorialTarget(panel, current)?.scrollIntoView({ block: "nearest", inline: "nearest" }); + measure(); + next.current?.focus({ preventScroll: true }); + // A card taller than a short panel scrolls within itself: its choices are brought into its view with the focus. + const box = card.current; + if (box && box.scrollHeight > box.clientHeight) box.scrollTop = box.scrollHeight; + }, [step, showing]); + + const place = tutorialPlace(shown, step); + const last = nextTutorialStep(shown, step) === null, first = previousTutorialStep(shown, step) === null; + function keys(event: KeyboardEvent) { + if (event.repeat && event.key !== "Escape") { event.preventDefault(); return; } + if (event.key === "Escape") onEnd(); + else if (event.key === "ArrowRight") onNext(); + else if (event.key === "ArrowLeft" && !first) onBack(); + else return; + event.preventDefault(); + event.stopPropagation(); + } + const box = (value: Box | null): CSSProperties => value ? { top: value.top, left: value.left, width: value.width } : {}; + return createPortal(<> + {layout.ring &&