From 47e71c474af634dc9f4db1b86eb666213ea09499 Mon Sep 17 00:00:00 2001 From: Alex TYRODE Date: Tue, 13 Oct 2026 12:11:27 +0000 Subject: [PATCH 1/8] Tutorial: offered once per person, walked over the real controls, replayed from More --- README.md | 6 +- docs/configuration.md | 36 +++++- plugins/code/checks.ts | 29 +---- plugins/code/contract.ts | 10 ++ plugins/code/generator/decision-model.ts | 36 ++++-- plugins/code/generator/styles.css | 19 +++ plugins/code/generator/tutorial-model.ts | 57 +++++++++ plugins/code/generator/tutorial.tsx | 151 +++++++++++++++++++++++ plugins/code/generator/web.tsx | 59 ++++++++- plugins/code/person.ts | 33 +++++ plugins/code/server.ts | 12 +- plugins/code/tutorial.ts | 32 +++++ plugins/code/workflow.ts | 2 + plugins/test/tutorial-model.test.ts | 22 ++++ plugins/test/workflow.test.ts | 20 +++ 15 files changed, 476 insertions(+), 48 deletions(-) create mode 100644 plugins/code/generator/tutorial-model.ts create mode 100644 plugins/code/generator/tutorial.tsx create mode 100644 plugins/code/person.ts create mode 100644 plugins/code/tutorial.ts create mode 100644 plugins/test/tutorial-model.test.ts 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..1f96b98 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 @@ -326,7 +336,8 @@ until it is reviewed there. When session options are set, More and its **Options 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. +`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,27 @@ 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 with no sheet open. 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 and points at the real controls one at a time: 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 has room, else over it), both laid over the +panel so nothing in it moves: the step's name, where the walk stands ("3 of 10", counting +only the steps the layout shows), one sentence, and **Next** (**Done** at the last), +**Back** and **Skip**; `→` and `←` step, `Esc` skips, and every step takes focus on Next +once, scrolling its control 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, and Next always +works too. 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) hides its card until it is. + ### 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..1e09e65 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 finished, skipped or left it, 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/styles.css b/plugins/code/generator/styles.css index 54c2f91..e911fc0 100644 --- a/plugins/code/generator/styles.css +++ b/plugins/code/generator/styles.css @@ -754,6 +754,25 @@ @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; 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-height: 44px; } +} /* 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..4a2f01b --- /dev/null +++ b/plugins/code/generator/tutorial.tsx @@ -0,0 +1,151 @@ +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. + */ +export function useTutorialSeen(host: HostServices): { readonly seen: boolean | null; readonly see: () => void } { + const [seen, setSeen] = useState(null); + const keeps = useRef(false); + const local = `atyrode.code.tutorial:${host.principal.id}`; + useEffect(() => { + let live = true; + setSeen(null); + codeWorkflow(host).readTutorial().then(record => { + if (!live) return; + keeps.current = record.keeps; + 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) { rememberInBrowser(local); return; } + codeWorkflow(host).seeTutorial().catch(() => { /* Offered again next time, as above. */ }); + } + return { seen, see }; +} +function browserSeen(key: string): boolean { + try { return globalThis.localStorage?.getItem(key) !== null; } 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 shown: readonly boolean[]; readonly card: Box | null; readonly ring: Box | null }; + +/** + * One step of the tutorial: a thin ring in the accent around the real control and a card beside it, + * under it where there is room in the panel, else over it, both laid over the panel without moving + * anything in it. The card says what the control is, where the walk stands ("3 of 10"), and offers + * Next (Done at the last), Back and Skip; `→` and `←` step too, and `Esc` skips. Each step takes + * focus on Next once, so the walk reads out and `↵` goes on, and scrolls its control into view. + * A step whose control is not shown now (another view or a sheet has the panel) hides the card and + * the ring until it is. Placed in the panel's own coordinates, so a panel inside a transformed + * canvas places it as well as one that is not. + */ +export function TutorialCard({ panel, step, accent, onNext, onBack, onEnd }: { + panel: HTMLElement; step: number; accent: string; onNext: () => void; onBack: () => void; onEnd: () => void; +}) { + const id = useId(); + const card = useRef(null); + const next = useRef(null); + const [layout, setLayout] = useState({ shown: [], card: null, ring: null }); + const current = TUTORIAL_STEPS[step]!; + + function measure() { + const shown = tutorialShown(panel), target = tutorialTarget(panel, current); + let placed: Layout = { shown, card: null, ring: null }; + if (target) { + const room = panel.getBoundingClientRect(), at = target.getBoundingClientRect(); + // What of the panel the window shows: a panel taller than the window keeps the card where it can be seen. + const 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, under = at.bottom - room.top + GAP_PX, over = top - GAP_PX - height; + const cardTop = under + height <= bottom - EDGE_PX ? under : over >= EDGE_PX ? over : Math.max(EDGE_PX, bottom - EDGE_PX - height); + const left = Math.min(Math.max(at.left - room.left, EDGE_PX), room.width - EDGE_PX - width); + const ringLeft = Math.max(1, at.left - room.left - RING_PX), ringTop = Math.max(1, top - RING_PX); + placed = { shown, card: { top: cardTop, left, width, height }, + ring: { top: ringTop, left: ringLeft, width: Math.min(room.width - 1, at.right - room.left + RING_PX) - ringLeft, + height: Math.min(room.height - 1, at.bottom - room.top + RING_PX) - ringTop } }; + } + 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; + useEffect(() => { + const view = panel.ownerDocument.defaultView, follow = () => latest.current(); + const observer = new ResizeObserver(follow); + observer.observe(panel); + panel.addEventListener("scroll", follow, true); + view?.addEventListener("resize", follow); + return () => { observer.disconnect(); panel.removeEventListener("scroll", follow, true); view?.removeEventListener("resize", follow); }; + }, [panel]); + // 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" }); + next.current?.focus({ preventScroll: true }); + }, [step, showing]); + + const place = tutorialPlace(layout.shown, step); + const last = nextTutorialStep(layout.shown, step) === null, first = previousTutorialStep(layout.shown, step) === null; + function keys(event: KeyboardEvent) { + 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 &&