diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be2533a..8985984 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,8 @@ jobs: - run: npm run typecheck - name: Validate AI literacy lesson and reviewed data run: node scripts/validate-ai-literacy.cjs + - name: Validate local Creative Filing contract + run: node scripts/test-creative-filing.mjs - name: Verify tests or expected starter state shell: bash run: | diff --git a/docs/ai-literacy/CREATIVE-FILING-VALIDATION.md b/docs/ai-literacy/CREATIVE-FILING-VALIDATION.md new file mode 100644 index 0000000..7bc1173 --- /dev/null +++ b/docs/ai-literacy/CREATIVE-FILING-VALIDATION.md @@ -0,0 +1,96 @@ +# Creative Filing implementation evidence + +Review candidate, 2026-11-08 UTC. No merge or production approval. +Canonical contract: https://docs.google.com/document/d/1RvA2lALsCae5daIOIpaqOCgflIBxhtJMVZj7bnyL5mg/edit +Read the complete updated document, including SAVE-1..6, typed records, archive contract and RT-01..12 / NEG-01..11. Document revision inspected: `AHj4eMSeSAQECkIVjoAvZJNSRP8u0RR16fo19augCX-yYeeFOKmAOxKbHCdPJbz-vHFUWinB-SCs_BQo5oWTB8KKuFmJ9B0-AXLW7s-lZio`. + +## Source and origin inventory + +- Training default branch inspected: `0b633cde9b671975fae28a481fbfe311b026479b`. PR #53 is merged. Open PRs #52, #50, #47 and #38 cover other work; no filing implementation assignment was duplicated. +- Companion default branch inspected read-only: `4b403db201500e9d2c79b148aff16529f8b1ef3f`. Reused the transaction-completion and optimistic-revision patterns from `workspace-browser.mjs` / `workspace.mjs`, and inspected `checkpoint-pointer.mjs` / `task-contract.mjs`. No Companion schema, authentication, checkpoint, Railway or OAuth changes. +- Confirmed canonical learner URL: `https://loptr-lab.github.io/training/`; repository `docs/index.html` canonical link and GitHub Pages API agree on main `/docs`. Storage origin is **https://loptr-lab.github.io**, not the `/training` path. Scripts on that same origin share the trust boundary. +- Existing Vercel team `loptr-lab`, project `training` (`prj_p7K2hjDRoQWoiOaCi1g7AdZWHYzF`). Inspected current production deployment `dpl_CwRqJcpFciMZQuZSASvpgoGQ2Vkm`: source repo Training/main at the above SHA. Existing production aliases include `training-bay-eight.vercel.app`. Preserve existing `docs/` static output; no project, production routing or origin migration. +- Each Vercel preview hostname, Vercel production alias, GitHub Pages origin, Safari/Home Screen container and browser profile may have a different Library. The UI displays its actual origin. Reuse one hostname for a test sequence; transfer only through explicit ZIP import/export. Never promise cross-origin synchronization. + +## Browser target and storage policy + +Implementation target: Safari/iPadOS **17+**, based on WebKit's documented full Storage API support, plus a current desktop browser. Repository inspection found no existing minimum Safari version or installed/offline launch requirement. This is a candidate implementation baseline; owner sign-off and actual device compatibility remain release gates. No service worker or offline shell was added. Loaded exercises work locally; offline navigation/first launch is not promised. + +Official sources checked 2026-11-08: + +- https://webkit.org/blog/14403/updates-to-storage-policy/ — full Storage API from Safari/iPadOS 17; quotas are ceilings, not allocated capacity; best-effort storage can be evicted. Features are detected; a persistence request may be denied. +- https://webkit.org/tracking-prevention/ — private storage is ephemeral, non-use policies and Home Screen exceptions are not a backup guarantee. +- https://support.apple.com/en-us/102440 — canonical specification's Apple Downloads/Files guide. Re-fetch this session returned HTTP 429; do not infer actual Blob/provider success from it. + +Physical iPad Safari, VoiceOver, Files/provider download, browser/process closure, Private Browsing retention and Home Screen isolation: **NOT RUN**. No device/OS/provider outcome is claimed. + +## Implementation and boundaries + +`rubrics.js` and `capstone.js` keep the existing grader, scenario generator, hints, study paths and Markdown downloads. Small snapshot/restore hooks feed an adapter in `filing/exercise.mjs`; no second grader or tool implementation. An untouched starter page creates no record. Actual writing, answering or explicit new practice creates a UUID and default title immediately, attempts initial persistence, then uses 500 ms inactivity / 2 s maximum debounce. Explicit completion and in-app navigation flush; visibility/pagehide are best effort. + +`filing/store.mjs` uses a separate local `pixie-creative-filing` IndexedDB v1 store. Writes serialize and validate inside a transaction, and resolve only on completion. Per-artifact revisions reject stale saves; library revision rejects stale import/organization plans. Failure retains both the memory draft and prior committed snapshot. Retry, separate working copy, full draft export, and explicitly labeled plaintext salvage are provided. These are not permanent backups. + +`filing/model.mjs` validates strict v1 fields, bounded UTF-8 byte sizes, labels, timestamps, references, signed results and capstone snapshot/answer-key consistency. It supports only the three declared v1 exercise adapters, rejecting unsupported versions. New work has null provenance; imports record source-byte hash/time/identity without inventing authorship. Imported grading is learner-produced; correctness against an included key is not external certification. + +Unfinished rubric form inputs, wording hints and quiz responses are preserved as an explicitly marked `writer-state/v1` **ungraded revision note**, using the specified note string fields rather than a new schema extension. That note has additional strict validation and must agree with typed criterion records. Existing grading and task snapshots survive resume; criteria changes invalidate only the affected previous result. Legacy text is never converted automatically. + +`library.html` and `filing/library.mjs` share Training typography/themes/reading settings. Library → Find → Recent → Labels supports rename Done/Escape, stable multiple label IDs/custom labels/label rename and confirmed deletion, NFC token search plus label intersection, deterministic order, duplicate display names, record actions and local-only recovery guidance. Export before delete is offered; there is no trash claim. + +Both single and all backups use the exact `pixie.creative-filing` v1 ZIP manifest/member/hash layout, input/member/total limits and safe filename contract. All includes unused label definitions. The bounded ZIP dependency review is in [filing/vendor/README.md](filing/vendor/README.md). Import validates bytes/hashes/schema before staging; preview supports Skip/default, separate-copy conflicts and explicit missing/conflicting-label mappings. A revision-checked transaction commits all or zero. Unchanged UUID/logical record and legacy source hashes skip; conflict-copy retry is idempotent. Explicit separate label copies use stable UUIDs derived from the exact archive hash and source label ID, including unused labels, to avoid duplicates on retry; an edited prior copy is never overwritten. Download feedback only claims archive prepared/download started, not a Files write. + +No artifact uploads, telemetry, filesystem scan, remote models, account creation, hidden sync or automatic GitHub publication. Artifact content/names/labels never enter request URLs; resume URLs contain only a local random UUID. Imports use textContent, never arbitrary HTML. Local storage is not encryption against same-origin scripts, extensions or the device user. + +## Automated evidence + +Commands from repository root: + +``` +npm run typecheck +node scripts/validate-ai-literacy.cjs +npm run test:filing +npm run test:filing:browser +node scripts/test-ai-literacy-browser.cjs +``` + +Browser runner needs Playwright and Chromium (existing review environment); `BROWSER_EXECUTABLE` and `BASE_URL` are overridable. To include axe, set `AXE_PATH` to an installed axe-core browser bundle. No browser-tool dependency is loaded by the product. Core contract checks run in existing CI with Node 20; existing CI behavior for starter tests is unchanged. + +Observed local results: typecheck PASS; source/link/signed-arithmetic validation PASS; 1,024 existing scenario variants PASS; 23 identified contract groups PASS; real Chromium filing flow PASS; existing lesson browser suite PASS. Axe-core 4.11.1 on the populated Library in both themes: zero WCAG A/AA violations. Doubled base text at widths 320/768/1024 reflows without page overflow. Screenshots and local browser evidence generated from synthetic fixtures under `/tmp/creative-filing-review`; no learner data. + +Historical baseline reproduced: `npm test -- --runInBand` has **24/24 starter engine failures**, all intentional `Not implemented`, in two suites. These engine files are unchanged. Do not report the entire repository suite as green. + +| ID | Automated portion | Physical portion | +| --- | --- | --- | +| RT-01 | No untouched-page artifact; actual edit creates/stores UUID; commit-only status | NOT RUN | +| RT-02 | Edit/reload input restoration; signed +4/-6 = -2 semantic ZIP round trip | NOT RUN | +| RT-03 | Actual one-action rename, Enter save, Escape cancel and focus return | NOT RUN | +| RT-04 | Multiple stable memberships and word-rename search; real checkboxes | NOT RUN | +| RT-05 | NFC/case/tokens, label intersection, empty results/Clear, deterministic model ordering | NOT RUN | +| RT-06 | Download event; member inventory/length/hash validation; STORE and independent DEFLATE writer compatibility | Apple Files NOT RUN | +| RT-07 | Identical import skips; cancel unchanged; isolated browser-profile import commits | Safari closure/device NOT RUN | +| RT-08 | Deep criteria/results/labels comparison after isolated restore; real exercise resume | Physical reopen NOT RUN | +| RT-09 | All export retains unused custom label; isolated restore retains it | NOT RUN | +| RT-10 | Legacy UTF-8 BOM/CRLF exact bytes; changed filename duplicate skip; native ZIP preserves failed first / incomplete second attempt; script markup inert | Existing real-device Markdown Files selection NOT RUN | +| RT-11 | Browser request capture: no unexpected method/origin; no content API in core | Physical network capture NOT RUN | +| RT-12 | Keyboard rename/focus, checked controls, native modal, axe, light/dark, doubled-text reflow | VoiceOver/touch/reduced-motion/device recording NOT RUN | +| NEG-01 | Actual quota fault preserves durable revision and memory input; draft download and Retry succeeds | Actual device quota exhaustion NOT RUN | +| NEG-02 | Uncommitted draft explicitly marked; transaction abort leaves previous snapshot | Actual process termination before commit NOT RUN | +| NEG-03 | Same ID/changed content requires Skip or new copy; original preserved | NOT RUN | +| NEG-04 | Truncation, hash change, duplicate ZIP path, unsupported schema rejected | NOT RUN | +| NEG-05 | Same display name/two UUIDs retained | Files collision dialogs NOT RUN | +| NEG-06 | Missing label repair / conflicting word mapping required; validation before commit | VoiceOver repair flow NOT RUN | +| NEG-07 | Real IDB write abort unchanged; staging/cancel no mutation; retry deduplicates | Actual process interruption NOT RUN | +| NEG-08 | Empty-store restoration from ZIP; destructive UI requires explicit confirmation | Actual device reset/eviction NOT RUN | +| NEG-09 | Real stale revision commit rejected; UI import stops and re-previews after concurrent mutation; failed working draft preserved | Physical two-tab timeline NOT RUN | +| NEG-10 | Traversal, invalid/nonfinite weights, mismatched result, oversized/bomb metadata, invalid UTF-8/binary rejected; inert markup | NOT RUN | +| NEG-11 | Feature detection, explicit persistence request, no retention guarantee | Private/Home Screen mode acceptance NOT RUN | + +These automated portions do not replace the full 23 physical acceptance cases in the canonical document. + +## Remaining production gates + +1. Review this implementation and vendored dependency, especially archive framing/limits, transaction/conflict behavior and the explicit resume-note adapter. +2. Owner acceptance of canonical Training origin and Safari/iPadOS 17+ baseline; no migration or cross-origin sharing implied. +3. Execute the canonical physical iPad Safari / VoiceOver / Files/provider, persistence/closure, conflict, private-mode and Home Screen checklist; record exact device, OS, browser, origin, clock/timezone, test IDs and evidence. +4. Review verified preview and explicitly approve merge/production. No approval is inferred from this implementation request. + +Preview HTTP/asset verification is recorded in the review PR after deployment; READY alone is not acceptance. diff --git a/docs/ai-literacy/VALIDATION.md b/docs/ai-literacy/VALIDATION.md index 0685cdb..3d9e921 100644 --- a/docs/ai-literacy/VALIDATION.md +++ b/docs/ai-literacy/VALIDATION.md @@ -49,3 +49,7 @@ Playwright/axe were available or installed only in the review workspace, not add - 1,024 generated scenario variants (512 seeds × two attempts): PASS. Independently recomputed synthetic prices, meal costs, shuttle allocation, per-person/party totals, printed-response comparisons and signed scoring. Verified study facts remain unchanged on the second attempt while all three response outcomes change; positive, negative and zero scores occur. - Browser assessment paths: first-attempt success; first failure with scenario guide and Cornell link; second-attempt success; two failures with observed number-checking, rule-checking or arithmetic routes; two failures with no demonstrated strength and an interest-based route. No third assessment attempt offered. Both attempts appear in the downloaded record. Existing eight-step quiz/retry/reset checks still pass. - No correct-answer feedback is revealed during a final assessment attempt; the completed attempt provides evidence and study. Local record is learner-produced, not independently verified. Reload starts a new session rather than enforcing a permanent learner failure count. + +## Creative Filing review candidate (2026-11-08 UTC) + +The local Library and rubric/capstone persistence candidate has its own [implementation evidence and RT/NEG matrix](CREATIVE-FILING-VALIDATION.md). Existing lesson/grading behavior and Markdown exports remain covered by the original checks above. Physical iPad/VoiceOver/Files acceptance is still NOT RUN; this addition is not production approval. diff --git a/docs/ai-literacy/capstone.js b/docs/ai-literacy/capstone.js index b6e72aa..0d41824 100644 --- a/docs/ai-literacy/capstone.js +++ b/docs/ai-literacy/capstone.js @@ -1,4 +1,4 @@ -/* Fresh, reproducible fictional practice; no AI service, persistence or network. */ +/* Fresh, reproducible fictional practice; no AI service or network. Creative Filing observes local changes. */ (function () { 'use strict'; function scenario(seed, variant = 0) { @@ -80,7 +80,7 @@ function fresh() { seed=window.crypto.getRandomValues(new Uint32Array(1))[0]; exercise=scenario(seed);round=1;stage=0;history=[];rounds=[];completed=false;retry.hidden=true; - document.getElementById('capstone-progress').textContent='No assessment attempts completed in this page session.'; + document.getElementById('capstone-progress').textContent='No assessment attempts completed in this practice record.'; document.getElementById('capstone-results').replaceChildren();renderScenario(); } form.addEventListener('submit',event=>{ @@ -130,7 +130,7 @@ } rounds[rounds.length-1].nextPath={label,href}; const link=appendText(p,'a',label);link.href=href;p.append(document.createTextNode('. These are learning suggestions, not assessments of abilities the exercise did not test.')); - appendText(box,'p','You can return to the lessons or ask for human feedback. The two-attempt record applies to this page session only; download it before leaving if you want to discuss it with PIXIE.'); + appendText(box,'p','You can return to the lessons or ask for human feedback. The two-attempt record stays in this browser’s Library after the local save commits. Export a backup before relying on it; deliberately share a record only if you choose.'); } function finish() { form.hidden=true; @@ -175,5 +175,17 @@ const link=document.createElement('a');link.href=url;link.download='synthetic-classroom-'+exercise.id+'.md';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000); }); document.getElementById('capstone-new').addEventListener('click',()=>{fresh();document.getElementById('capstone-id').focus();}); + window.trainingCapstone={ + snapshot:()=>{const attempts=rounds.map(r=>({round:r.round,exercise:r.exercise,answers:r.answers.map(({step,answer,correct})=>({step,answer,correct})),...(r.studyGuide?{studyGuide:r.studyGuide}:{}),...(r.nextPath?{nextPath:r.nextPath}:{})}));if(!attempts.some(a=>a.round===round))attempts.push({round,exercise,answers:history.filter(a=>a.round===round).map(({step,answer,correct})=>({step,answer,correct}))});return {seed,scenarioId:exercise.id,attempts,completed};}, + restore:content=>{ + seed=content.seed;const last=content.attempts.at(-1);round=last.round;exercise=last.exercise; + history=content.attempts.flatMap(a=>a.answers.map(h=>({...h,round:a.round}))); + rounds=content.attempts.filter(a=>a.answers.length===4).map(a=>({...a,correct:a.answers.filter(h=>h.correct).length,passed:a.answers.every(h=>h.correct)})); + stage=last.answers.length;completed=content.status==='complete';retry.hidden=true; + document.getElementById('capstone-results').replaceChildren();renderScenario(); + if(stage===4){rounds=rounds.filter(a=>a.round!==round);finish();}else renderQuestion(); + document.getElementById('capstone-new').disabled=history.length>0; + } + }; fresh();root.hidden=false; })(); diff --git a/docs/ai-literacy/filing/archive.mjs b/docs/ai-literacy/filing/archive.mjs new file mode 100644 index 0000000..4b9fa08 --- /dev/null +++ b/docs/ai-literacy/filing/archive.mjs @@ -0,0 +1,522 @@ +import { Inflate } from "./vendor/fflate.mjs"; +import { + FORMAT, + LIMIT, + shape, + id, + timestamp, + label, + artifact, + validate, + clone, + hash, + jsonBytes, + bytes, + uuid, + now, + logical, + create, + fail, + safeName, +} from "./model.mjs"; +const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); +const decode = (b) => decoder.decode(b); +const parse = (b) => { + const text = decode(b); + if (text.charCodeAt(0) === 0xfeff) fail("JSON BOM is unsupported."); + return JSON.parse(text); +}; +export function crc32(data) { + let crc = -1; + for (const b of data) { + crc ^= b; + for (let i = 0; i < 8; i++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + return (crc ^ -1) >>> 0; +} +const u16 = (b, o, v) => + new DataView(b.buffer, b.byteOffset, b.byteLength).setUint16(o, v, true); +const u32 = (b, o, v) => + new DataView(b.buffer, b.byteOffset, b.byteLength).setUint32(o, v, true); +function join(parts) { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let p = 0; + parts.forEach((b) => { + out.set(b, p); + p += b.length; + }); + return out; +} +/* STORE writer: predictable memory, no archive extraction or filesystem paths. */ +export function zip(members) { + let offset = 0; + const local = [], + central = []; + for (const [name, data] of members) { + const n = bytes(name), + crc = crc32(data), + h = new Uint8Array(30 + n.length), + c = new Uint8Array(46 + n.length); + u32(h, 0, 0x04034b50); + u16(h, 4, 20); + u16(h, 6, 0x800); + u32(h, 14, crc); + u32(h, 18, data.length); + u32(h, 22, data.length); + u16(h, 26, n.length); + h.set(n, 30); + u32(c, 0, 0x02014b50); + u16(c, 4, 20); + u16(c, 6, 20); + u16(c, 8, 0x800); + u32(c, 16, crc); + u32(c, 20, data.length); + u32(c, 24, data.length); + u16(c, 28, n.length); + u32(c, 42, offset); + c.set(n, 46); + local.push(h, data); + central.push(c); + offset += h.length + data.length; + } + const directory = join(central), + end = new Uint8Array(22); + u32(end, 0, 0x06054b50); + u16(end, 8, members.length); + u16(end, 10, members.length); + u32(end, 12, directory.length); + u32(end, 16, offset); + const out = join([...local, directory, end]); + if (out.length > LIMIT.bytes) fail("ZIP exceeds 20 MiB input limit."); + return out; +} +export function unzip(input) { + if ( + !(input instanceof Uint8Array) || + input.length > LIMIT.bytes || + input.length < 22 + ) + fail("ZIP size is invalid."); + const view = new DataView(input.buffer, input.byteOffset, input.byteLength), + r16 = (o) => view.getUint16(o, true), + r32 = (o) => view.getUint32(o, true); + let end = -1; + for (let i = input.length - 22; i >= Math.max(0, input.length - 65557); i--) + if (r32(i) === 0x06054b50 && i + 22 + r16(i + 20) === input.length) { + end = i; + break; + } + if (end < 0) fail("Incomplete ZIP directory."); + const count = r16(end + 10), + start = r32(end + 16), + size = r32(end + 12); + if ( + r16(end + 4) || + r16(end + 6) || + r16(end + 8) !== count || + count > 102 || + count < 2 || + start + size !== end + ) + fail("Unsupported multivolume/ZIP64 directory."); + let cursor = start, + total = 0; + const entries = [], + seen = new Set(); + for (let i = 0; i < count; i++) { + if (cursor + 46 > end || r32(cursor) !== 0x02014b50) + fail("Corrupt ZIP directory."); + const flags = r16(cursor + 8), + method = r16(cursor + 10), + crc = r32(cursor + 16), + compressed = r32(cursor + 20), + length = r32(cursor + 24), + nl = r16(cursor + 28), + extra = r16(cursor + 30), + comment = r16(cursor + 32), + external = r32(cursor + 38), + offset = r32(cursor + 42); + if ( + cursor + 46 + nl + extra + comment > end || + r16(cursor + 34) || + flags & ~0x808 || + ![0, 8].includes(method) || + ((external >>> 16) & 0xf000) === 0xa000 + ) + fail("Unsupported encrypted, symlink or ZIP method."); + const name = decode(input.subarray(cursor + 46, cursor + 46 + nl)); + if ( + !/^(manifest\.json|labels\.json|artifacts\/[0-9a-f-]{36}\.json)$/.test( + name, + ) || + seen.has(name) + ) + fail("Unsafe or duplicate ZIP path."); + seen.add(name); + if ( + length > + (name.startsWith("artifacts/") ? LIMIT.artifactBytes : LIMIT.bytes) || + (total += length) > LIMIT.bytes + ) + fail("Decompressed archive exceeds limit."); + if ( + offset + 30 > start || + r32(offset) !== 0x04034b50 || + r16(offset + 6) !== flags || + r16(offset + 8) !== method + ) + fail("Invalid local ZIP header."); + const localNL = r16(offset + 26), + localExtra = r16(offset + 28), + dataStart = offset + 30 + localNL + localExtra, + dataEnd = dataStart + compressed; + if ( + dataEnd > start || + decode(input.subarray(offset + 30, offset + 30 + localNL)) !== name + ) + fail("ZIP local path mismatch."); + if ( + !(flags & 8) && + (r32(offset + 14) !== crc || + r32(offset + 18) !== compressed || + r32(offset + 22) !== length) + ) + fail("ZIP local length mismatch."); + let rangeEnd = dataEnd; + if (flags & 8) { + let p = dataEnd; + if (p + 4 <= start && r32(p) === 0x08074b50) p += 4; + if ( + p + 12 > start || + r32(p) !== crc || + r32(p + 4) !== compressed || + r32(p + 8) !== length + ) + fail("Invalid ZIP data descriptor."); + rangeEnd = p + 12; + } + entries.push({ + name, + method, + crc, + length, + dataStart, + dataEnd, + offset, + rangeEnd, + }); + cursor += 46 + nl + extra + comment; + } + if (cursor !== end) fail("Trailing ZIP directory data."); + const ranges = [...entries].sort((a, b) => a.offset - b.offset); + let boundary = 0; + for (const e of ranges) { + if (e.offset !== boundary) fail("Overlapping or unlisted ZIP data."); + boundary = e.rangeEnd; + } + if (boundary !== start) fail("Unlisted ZIP data."); + const result = new Map(); + for (const e of entries) { + const source = input.subarray(e.dataStart, e.dataEnd); + let output; + if (e.method === 0) { + if (source.length !== e.length) fail("Stored ZIP length mismatch."); + output = source.slice(); + } else { + let used = 0; + const chunks = []; + const inflate = new Inflate((chunk) => { + used += chunk.length; + if (used > e.length || used > LIMIT.bytes) + fail("Decompression output exceeds declared bound."); + chunks.push(chunk.slice()); + }); + if (!source.length) inflate.push(source, true); + for (let p = 0; p < source.length; p += 1024) + inflate.push(source.subarray(p, p + 1024), p + 1024 >= source.length); + output = join(chunks); + } + if (output.length !== e.length || crc32(output) !== e.crc) + fail("ZIP integrity check failed."); + result.set(e.name, output); + } + return result; +} +export async function exportArchive( + state, + selected = null, + persistence = "committed", +) { + validate(state); + const artifacts = selected + ? state.artifacts.filter((a) => a.id === selected) + : state.artifacts; + if (selected && artifacts.length !== 1) fail("Selected work is missing."); + const labels = selected + ? state.labels.filter((l) => artifacts[0].labels.includes(l.id)) + : state.labels; + const members = [ + ["labels.json", jsonBytes({ schemaVersion: 1, labels })], + ...artifacts.map((a) => ["artifacts/" + a.id + ".json", jsonBytes(a)]), + ]; + const manifest = { + format: FORMAT, + schemaVersion: 1, + exportedAt: now(), + persistence, + selection: selected ? "single" : "all", + artifactIds: artifacts.map((a) => a.id), + files: await Promise.all( + members.map(async ([path, data]) => ({ + path, + bytes: data.length, + sha256: await hash(data), + })), + ), + }; + const m = jsonBytes(manifest); + if (members.reduce((n, [, b]) => n + b.length, m.length) > LIMIT.bytes) + fail("Archive exceeds 20 MiB."); + const stamp = manifest.exportedAt.replace(/[-:.]/g, ""); + return { + data: zip([["manifest.json", m], ...members]), + name: selected + ? `pixie-${safeName(artifacts[0].name)}-${stamp}-${selected.slice(0, 8)}.zip` + : `pixie-library-${stamp}-${uuid().slice(0, 8)}.zip`, + }; +} +export async function readArchive(input, filename) { + if (input.length > LIMIT.bytes) fail("Input exceeds 20 MiB."); + const sourceHash = await hash(input); + if (/\.md$/i.test(filename)) { + const text = decode(input); + if (text.includes("\0")) fail("Binary Markdown is unsupported."); + const a = create( + { id: "legacy-markdown", title: "Imported Markdown", version: "1" }, + { + type: "legacy/markdown-v0", + status: "draft", + text, + originalFilename: filename, + }, + ); + a.name = [...filename].slice(0, 160).join("").trim() || "Imported Markdown"; + a.provenance = { + sourceFormat: "legacy/markdown-v0", + sourceHash, + importedAt: now(), + sourceArtifactId: null, + }; + artifact(a, []); + return { artifacts: [a], labels: [], sourceHash, legacy: true }; + } + if (!/\.zip$/i.test(filename)) + fail("Select a Creative Filing .zip or UTF-8 .md file."); + const files = unzip(input); + if (!files.has("manifest.json") || !files.has("labels.json")) + fail("Required archive members missing."); + const m = parse(files.get("manifest.json")); + shape(m, [ + "format", + "schemaVersion", + "exportedAt", + "persistence", + "selection", + "artifactIds", + "files", + ]); + if (m.format !== FORMAT || m.schemaVersion !== 1) + fail("Unsupported archive format/version."); + timestamp(m.exportedAt); + if ( + !["committed", "includes-uncommitted-draft"].includes(m.persistence) || + !["single", "all"].includes(m.selection) || + !Array.isArray(m.artifactIds) || + m.artifactIds.length > 100 || + new Set(m.artifactIds).size !== m.artifactIds.length || + (m.selection === "single" && m.artifactIds.length !== 1) + ) + fail("Invalid archive selection."); + m.artifactIds.forEach(id); + if ( + !Array.isArray(m.files) || + m.files.length !== files.size - 1 || + new Set(m.files.map((f) => f.path)).size !== m.files.length + ) + fail("Unlisted or repeated archive members."); + const expected = [ + "labels.json", + ...m.artifactIds.map((id) => "artifacts/" + id + ".json"), + ]; + for (const f of m.files) { + shape(f, ["path", "bytes", "sha256"]); + const b = files.get(f.path); + if ( + !expected.includes(f.path) || + !b || + f.bytes !== b.length || + !/^[a-f0-9]{64}$/.test(f.sha256) || + (await hash(b)) !== f.sha256 + ) + fail("Archive hash/length mismatch."); + } + if (expected.some((p) => !m.files.some((f) => f.path === p))) + fail("Missing declared member."); + const ls = parse(files.get("labels.json")); + shape(ls, ["schemaVersion", "labels"]); + if ( + ls.schemaVersion !== 1 || + !Array.isArray(ls.labels) || + ls.labels.length > 1000 + ) + fail("Unsupported labels schema."); + ls.labels.forEach(label); + if (new Set(ls.labels.map((l) => l.id)).size !== ls.labels.length) + fail("Duplicate label IDs."); + const artifacts = m.artifactIds.map((id) => { + const a = parse(files.get("artifacts/" + id + ".json")); + if (a.id !== id) fail("Artifact filename identity mismatch."); + return artifact(a, ls.labels, { allowMissing: true }); + }); + return { + artifacts, + labels: ls.labels, + sourceHash, + legacy: false, + persistence: m.persistence, + }; +} +export function planImport(source, state, choices = {}) { + validate(state); + const next = clone(state), + mapping = new Map(), + issues = [], + summary = { added: 0, copied: 0, skipped: 0, drafts: 0 }; + const referenced = new Set(source.artifacts.flatMap((a) => a.labels)); + for (const lid of new Set([ + ...source.labels.map((l) => l.id), + ...referenced, + ])) { + const incoming = source.labels.find((l) => l.id === lid), + existing = state.labels.find((l) => l.id === lid), + choice = choices["label:" + lid]; + if (!incoming) { + if (choice === "omit") { + mapping.set(lid, null); + continue; + } + if ( + choice?.startsWith("map:") && + state.labels.some((l) => l.id === choice.slice(4)) + ) { + mapping.set(lid, choice.slice(4)); + continue; + } + issues.push({ type: "missing-label", id: lid, word: lid }); + continue; + } + if ( + existing && + JSON.stringify([existing.word, existing.color]) !== + JSON.stringify([incoming.word, incoming.color]) + ) { + if (choice === "existing") mapping.set(lid, lid); + else if (choice === "copy") { + // Source-byte identity plus source label ID gives this explicit copy a + // stable pseudorandom UUID, so retry cannot create unused duplicate labels. + // This never merges equal words carried by unrelated UUIDs/archives. + const sourceId = lid.replaceAll("-", ""); + let hex = Array.from({ length: 32 }, (_, i) => + ( + parseInt(source.sourceHash[i], 16) ^ parseInt(sourceId[i], 16) + ).toString(16), + ); + hex[12] = "4"; + hex[16] = ((parseInt(hex[16], 16) & 3) | 8).toString(16); + hex = hex.join(""); + const copyId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + const previous = state.labels.find((l) => l.id === copyId); + if ( + previous && + JSON.stringify([previous.word, previous.color]) !== + JSON.stringify([incoming.word, incoming.color]) + ) + fail( + "A previous separate label copy was edited. Choose the existing label mapping or cancel; it will not be overwritten.", + ); + if (!previous) next.labels.push({ ...incoming, id: copyId }); + mapping.set(lid, copyId); + } else + issues.push({ + type: "label", + id: lid, + word: incoming.word, + existing: existing.word, + }); + } else { + mapping.set(lid, lid); + if (!existing) next.labels.push(clone(incoming)); + } + } + for (const original of source.artifacts) { + const existing = state.artifacts.find((a) => a.id === original.id); + if ( + source.legacy && + state.artifacts.some( + (a) => + a.provenance?.sourceFormat === "legacy/markdown-v0" && + a.provenance.sourceHash === source.sourceHash, + ) + ) { + summary.skipped++; + continue; + } + const a = clone(original); + a.labels = [ + ...new Set(a.labels.map((l) => mapping.get(l)).filter((l) => l !== null)), + ]; + if (a.labels.includes(undefined)) continue; + if (existing && logical(existing) === logical(a)) { + summary.skipped++; + continue; + } + const choice = choices["artifact:" + original.id]; + if (existing && choice !== "copy") { + summary.skipped++; + if (choice !== "skip") + issues.push({ type: "artifact", id: original.id, name: original.name }); + continue; + } + if (existing) { + a.id = uuid(); + a.name = [...(a.name + " (restored copy)")].slice(0, 160).join(""); + a.revision = 1; + } + a.provenance = { + sourceFormat: source.legacy ? "legacy/markdown-v0" : FORMAT, + sourceHash: source.sourceHash, + importedAt: now(), + sourceArtifactId: source.legacy ? null : original.id, + }; + // A previous conflict copy of this exact source makes retries idempotent. + if ( + state.artifacts.some( + (v) => + v.name === a.name && + v.provenance?.sourceHash === source.sourceHash && + v.provenance?.sourceArtifactId === original.id && + logical({ ...v, id: a.id, name: original.name }) === + logical({ ...a, name: original.name }), + ) + ) { + summary.skipped++; + continue; + } + next.artifacts.push(a); + if (existing) summary.copied++; + summary.added++; + if (a.content.status === "draft") summary.drafts++; + } + if (!issues.length) validate(next); + return { next, issues, summary, revision: state.revision }; +} diff --git a/docs/ai-literacy/filing/exercise.mjs b/docs/ai-literacy/filing/exercise.mjs new file mode 100644 index 0000000..cf0a1b5 --- /dev/null +++ b/docs/ai-literacy/filing/exercise.mjs @@ -0,0 +1,369 @@ +import { create, uuid, clone } from "./model.mjs"; +import { openStore, Draft } from "./store.mjs"; +import { exportArchive } from "./archive.mjs"; +import { rename, download } from "./shared.mjs"; +const store = openStore(); +const title = document.getElementById("filing-title"), + status = document.getElementById("filing-status"), + failure = document.getElementById("filing-failure"); +let draft, + kind = "rubric-writing", + loading = true; +let rubricSnapshot; +const byKind = new Map(), + sessions = new Set(); +function state(message, error) { + if (status.textContent !== message) status.textContent = message; + failure.hidden = + message === "Saved on this device" || + (message !== "Could not save" && !draft?.error); + document.getElementById("filing-error").textContent = error || ""; +} +function base() { + if (rubricSnapshot) + return { + ...clone(rubricSnapshot), + criteria: [], + gradingResults: [], + status: "draft", + }; + const section = document.getElementById("fiction").parentElement; + const lists = section.querySelectorAll("ul"); + return { + type: "practice/structured-v1", + taskPrompt: section.querySelector("h3 + p").textContent, + inputFacts: Array.from(lists[0].children, (x) => x.textContent), + fictionalDeliverable: Array.from(document.querySelectorAll("p")).find((p) => + p.textContent.startsWith("Independent exercise:"), + ).textContent, + criteria: [], + gradingResults: [], + revisionNotes: [], + status: "draft", + }; +} +function content() { + if (kind === "synthetic-classroom") { + const cap = window.trainingCapstone.snapshot(), + last = cap.attempts.at(-1), + e = last.exercise; + return { + type: "practice/structured-v1", + taskPrompt: e.prompt, + inputFacts: e.facts, + fictionalDeliverable: e.response.join("\n"), + criteria: e.criteria.map((c, i) => ({ + id: "capstone-" + (i + 1), + text: c.text, + weight: c.weight, + origin: c.origin, + reason: "Synthetic task requirement.", + })), + gradingResults: e.criteria.map((c, i) => ({ + criterionId: "capstone-" + (i + 1), + outcome: + last.answers.length === 4 ? (c.pass ? "PASS" : "FAIL") : "ungraded", + evidence: last.answers.length === 4 ? c.evidence : "", + points: last.answers.length === 4 ? (c.pass ? c.weight : 0) : null, + })), + revisionNotes: [], + status: cap.completed ? "complete" : "draft", + seed: cap.seed, + scenarioId: cap.scenarioId, + attempts: cap.attempts, + }; + } + const r = window.trainingRubric.snapshot(), + c = base(); + c.criteria = r.drafts.map((d, i) => ({ + id: rubricSnapshot?.criteria[i]?.id || "rubric-" + (i + 1), + text: d.criterion, + weight: d.weight, + origin: d.origin, + reason: d.reason, + })); + c.gradingResults = c.criteria.map((cr) => ({ + criterionId: cr.id, + ...(rubricSnapshot?.criteria.some( + (old) => JSON.stringify(old) === JSON.stringify(cr), + ) + ? rubricSnapshot.gradingResults.find((r) => r.criterionId === cr.id) + : null), + ...(!rubricSnapshot?.criteria.some( + (old) => JSON.stringify(old) === JSON.stringify(cr), + ) || !rubricSnapshot.gradingResults.some((r) => r.criterionId === cr.id) + ? { outcome: "ungraded", evidence: "", points: null } + : {}), + })); + // Resume-only input snapshot uses an explicit, ungraded revision note, not a schema extension or claimed score. + c.revisionNotes = [ + ...c.revisionNotes.filter( + (n) => n.feedback !== "Local resume state: writer-state/v1", + ), + { + before: r.form[0], + feedback: "Local resume state: writer-state/v1", + after: JSON.stringify(r), + reason: "Unfinished inputs and lesson responses; no grading claim.", + }, + ]; + return c; +} +function attach(record, revision) { + title.textContent = record.name; + let instance; + instance = new Draft(store, record, revision, (message, error) => { + if (draft === instance) state(message, error); + }); + draft = instance; + byKind.set(kind, draft); + sessions.add(draft); +} +function capture(event) { + if (loading) return; + const target = event.target; + const nextKind = target.closest("#fresh-practice") + ? "synthetic-classroom" + : "rubric-writing"; + if (draft && nextKind !== kind) { + draft.flush(); + kind = nextKind; + draft = byKind.get(kind) || null; + if (draft) { + title.textContent = draft.record.name; + history.replaceState(null, "", "?artifact=" + draft.record.id); + } + } + kind = nextKind; + if (!draft) { + const record = create( + { + id: kind, + title: + kind === "synthetic-classroom" + ? "Synthetic classroom practice" + : "Rubric writing", + version: "1", + }, + content(), + ); + attach(record, 0); + history.replaceState(null, "", "?artifact=" + record.id); + draft.edit(record); + draft.flush(); + } else { + const updated = content(); + if (JSON.stringify(updated) === JSON.stringify(draft.record.content)) + return; + draft.record.content = updated; + draft.edit(draft.record); + if ( + (target.id === "keep-criterion" && + draft.record.content.criteria.length === 3) || + (target.id === "capstone-next" && + draft.record.content.attempts?.at(-1).answers.length === 4) + ) + draft.flush(); + } +} +try { + const state = await store.read(), + requested = new URL(location.href).searchParams.get("artifact"); + if (requested) { + const record = state.artifacts.find((a) => a.id === requested); + if (!record) + throw new Error( + "This work is not available on this browser origin. Open Library or import a backup.", + ); + if (record.content.type !== "practice/structured-v1") + throw new Error("Read legacy Markdown in Library."); + kind = record.exercise.id; + attach(record, record.revision); + if (kind === "synthetic-classroom") + window.trainingCapstone.restore(record.content); + else { + rubricSnapshot = clone(record.content); + const section = document.getElementById("fiction").parentElement, + lists = section.querySelectorAll("ul"); + section.querySelector("h3 + p").textContent = record.content.taskPrompt; + document.getElementById("fiction").textContent = + "Your saved fictional task snapshot"; + lists[1].previousElementSibling.textContent = + "Saved fictional deliverable"; + for (const [list, values] of [ + [lists[0], record.content.inputFacts], + [lists[1], record.content.fictionalDeliverable.split("\n")], + ]) { + list.replaceChildren(); + for (const value of values) { + const li = document.createElement("li"); + li.textContent = value; + list.append(li); + } + } + const note = record.content.revisionNotes.find( + (n) => n.feedback === "Local resume state: writer-state/v1", + ); + if (note) { + const r = JSON.parse(note.after); + if ( + !Array.isArray(r.drafts) || + !Array.isArray(r.form) || + r.form.length !== 4 || + !r.form.every((x) => typeof x === "string") || + !["explicit", "implicit"].includes(r.form[2]) + ) + throw new Error( + "Unsupported resume note. Original content remains in Library.", + ); + window.trainingRubric.restore(r, record.content); + } else + window.trainingRubric.restore( + { + drafts: record.content.criteria.map((c) => ({ + criterion: c.text, + weight: c.weight, + origin: c.origin, + reason: c.reason, + })), + form: ["", "2", "explicit", ""], + feedback: "", + reviews: 0, + answers: [], + score: "", + }, + record.content, + ); + } + status.textContent = "Saved on this device"; + } +} catch (e) { + state("Could not save", e.message); +} +loading = false; +for (const selector of [ + "#writer", + ".rubric-question", + "#score-practice", + "#fresh-practice", +]) + for (const el of document.querySelectorAll(selector)) { + el.addEventListener("input", capture); + el.addEventListener("change", capture); + el.addEventListener("submit", (e) => queueMicrotask(() => capture(e))); + } +for (const id of [ + "keep-criterion", + "reset-writer", + "capstone-new", + "capstone-next", + "capstone-retry", +]) + document.getElementById(id)?.addEventListener("click", (e) => + queueMicrotask(() => { + if (id === "capstone-new") { + if (draft && kind === "synthetic-classroom") { + draft.flush(); + draft = null; + } + byKind.delete("synthetic-classroom"); + } + capture(e); + }), + ); +document + .getElementById("written-criteria") + .nextElementSibling.addEventListener("click", (e) => + queueMicrotask(() => capture(e)), + ); +title.onclick = () => { + if (!draft) return; + rename( + draft.record, + title, + async (name) => { + draft.record.name = name; + draft.edit(draft.record); + if (!(await draft.flush())) + throw new Error(draft.error?.message || "Could not save"); + title.textContent = name; + }, + (message) => { + if (message === "Name updated.") + status.textContent = "Saved on this device"; + else state("Could not save", message); + }, + ); +}; +document.getElementById("filing-retry").onclick = () => draft?.flush(); +document.getElementById("filing-copy").onclick = () => { + if (!draft) return; + const previous = draft; + const copy = clone(draft.record); + copy.id = uuid(); + copy.revision = 1; + copy.name = [...(copy.name + " (working copy)")].slice(0, 160).join(""); + attach(copy, 0); + sessions.delete(previous); + history.replaceState(null, "", "?artifact=" + copy.id); + draft.edit(copy); + draft.flush(); +}; +document.getElementById("filing-export").onclick = async () => { + if (!draft) return; + try { + const s = await store + .read() + .catch(() => ({ revision: 0, artifacts: [], labels: [] })); + s.artifacts = s.artifacts.filter((a) => a.id !== draft.record.id); + s.artifacts.push(clone(draft.record)); + const result = await exportArchive( + s, + draft.record.id, + "includes-uncommitted-draft", + ); + download(result.data, result.name); + status.textContent = + "Archive prepared; download started. This draft is not saved locally. Check Downloads or Files."; + } catch (e) { + download( + JSON.stringify(draft.record, null, 2), + "pixie-draft-salvage.txt", + "text/plain", + ); + state( + "Could not save", + "Full archive unavailable: " + + e.message + + " Plain-text salvage download started; it is not a restorable backup.", + ); + } +}; +document.addEventListener("visibilitychange", () => { + if (document.hidden) for (const session of sessions) session.flush(); +}); +window.addEventListener("pagehide", () => { + for (const session of sessions) session.flush(); +}); +document.addEventListener("click", async (e) => { + const a = e.target.closest("a[href]"); + if ( + !a || + a.hasAttribute("download") || + !["http:", "https:"].includes(a.protocol) || + ![...sessions].some((s) => s.dirty) || + a.origin !== location.origin || + a.hash || + e.metaKey || + e.ctrlKey + ) + return; + e.preventDefault(); + if ((await Promise.all([...sessions].map((s) => s.flush()))).every(Boolean)) + location.href = a.href; + else + state( + "Could not save", + "A working draft could not be saved. Return to that exercise section and export it before leaving.", + ); +}); diff --git a/docs/ai-literacy/filing/library.mjs b/docs/ai-literacy/filing/library.mjs new file mode 100644 index 0000000..ec2fb5a --- /dev/null +++ b/docs/ai-literacy/filing/library.mjs @@ -0,0 +1,497 @@ +import { clone, uuid, now, find, validate } from "./model.mjs"; +import { openStore } from "./store.mjs"; +import { exportArchive, readArchive, planImport } from "./archive.mjs"; +import { element, download, rename } from "./shared.mjs"; +const store = openStore(), + $ = (id) => document.getElementById(id); +let current, + selected = "", + source, + plan, + choices = {}, + previewState; +const announce = (message) => { + if ($("library-status").textContent !== message) + $("library-status").textContent = message; +}; +$("storage-origin").textContent = location.origin; +async function load() { + try { + current = await store.read(); + render(); + announce("Library loaded on this device."); + $("storage-retry").hidden = true; + } catch (e) { + announce("Could not load Library: " + e.message); + $("storage-retry").hidden = false; + $("export-all").disabled = true; + $("import-file").disabled = true; + } +} +$("storage-retry").onclick = load; +async function commit(next, message) { + const result = await store.commit(current.revision, next); + current = result; + render(); + announce(message); +} +function render() { + $("export-all").disabled = false; + $("import-file").disabled = false; + renderResults(); + $("label-views").replaceChildren(); + $("label-management").replaceChildren(); + for (const l of current.labels) { + const button = element("button", l.word, $("label-views"), { + type: "button", + "aria-pressed": String(l.id === selected), + }); + button.onclick = () => { + selected = selected === l.id ? "" : l.id; + render(); + [...$("label-views").querySelectorAll("button")] + .find((b) => b.textContent === l.word) + ?.focus(); + }; + const row = element("details", "", $("label-management")); + element("summary", "Manage label: " + l.word, row); + const r = element("button", "Rename label " + l.word, row, { + type: "button", + }); + r.onclick = () => + rename( + { name: l.word }, + r, + async (word) => { + if ([...word].length > 40) + throw new Error("Labels have at most 40 characters."); + const next = clone(current); + next.labels.find((x) => x.id === l.id).word = word; + const focusId = l.id; + await commit(next, "Label renamed everywhere."); + $("label-views") + .querySelectorAll("button") + [current.labels.findIndex((x) => x.id === focusId)]?.focus(); + }, + announce, + ); + const del = element("button", "Delete label " + l.word, row, { + type: "button", + }); + del.onclick = async () => { + const count = current.artifacts.filter((a) => + a.labels.includes(l.id), + ).length; + if ( + !confirm( + "Delete label “" + + l.word + + "” from " + + count + + " works? The works remain.", + ) + ) + return; + try { + const next = clone(current); + next.labels = next.labels.filter((x) => x.id !== l.id); + next.artifacts.forEach((a) => { + if (a.labels.includes(l.id)) { + a.labels = a.labels.filter((id) => id !== l.id); + a.revision++; + a.modified = now(); + } + }); + if (selected === l.id) selected = ""; + await commit(next, "Label deleted; work preserved."); + $("label-word").focus(); + } catch (e) { + announce(e.message); + } + }; + } +} +function renderResults() { + const openMenus = new Set( + [...$("recent").querySelectorAll("article")] + .filter((a) => a.querySelector("details")?.open) + .map((a) => a.dataset.id), + ); + const results = find(current, $("find").value, selected); + const resultCount = + results.length + + " result" + + (results.length === 1 ? "" : "s") + + (selected + ? " · " + current.labels.find((l) => l.id === selected)?.word + : ""); + if ($("result-count").textContent !== resultCount) + $("result-count").textContent = resultCount; + $("recent").replaceChildren(); + if (!results.length) { + element( + "p", + current.artifacts.length + ? "No matches. Clear search and label to see all work." + : "No saved work yet. Start an exercise or import a backup.", + $("recent"), + ); + return; + } + for (const a of results) { + const row = element("article", "", $("recent"), { class: "filing-record" }), + heading = element("h3", "", row), + button = element("button", a.name, heading, { + type: "button", + "aria-label": "Rename " + a.name, + }); + row.dataset.id = a.id; + button.onclick = () => + rename( + a, + button, + async (name) => { + const next = clone(current), + record = next.artifacts.find((x) => x.id === a.id); + record.name = name; + record.modified = now(); + record.revision++; + await commit(next, "Name saved."); + if ( + !find(current, $("find").value, selected).some((x) => x.id === a.id) + ) { + $("find").focus(); + return "Name saved. This work moved out of the current results."; + } else + [...$("recent").querySelectorAll("h3 button")] + .find((b) => b.textContent === name) + ?.focus(); + }, + announce, + ); + element( + "p", + a.exercise.title + + " · " + + new Date(a.modified).toLocaleString() + + " · " + + a.content.status, + row, + ); + element( + "p", + a.labels + .map((id) => current.labels.find((l) => l.id === id)?.word) + .join(" · ") || "No labels", + row, + ); + const menu = element("details", "", row); + menu.open = openMenus.has(a.id); + element("summary", "Actions for " + a.name, menu); + if (a.content.type === "practice/structured-v1") + element("a", "Resume " + a.name, menu, { + href: "rubrics.html?artifact=" + a.id, + class: "button", + }); + else { + const read = element("button", "Read original Markdown", menu, { + type: "button", + }); + read.onclick = () => { + const pre = element("pre", a.content.text, menu); + read.disabled = true; + }; + const original = element("button", "Download original Markdown", menu, { + type: "button", + }); + original.onclick = () => { + download( + a.content.text, + a.content.originalFilename.replace(/[\/\\]/g, "-") || "practice.md", + "text/markdown", + ); + announce("Markdown download started. Check Downloads or Files."); + }; + } + const labels = element("fieldset", "", menu); + element("legend", "Labels for " + a.name, labels); + for (const l of current.labels) { + const wrap = element("label", "", labels, { class: "choice" }), + input = element("input", "", wrap, { + type: "checkbox", + "aria-label": l.word + " for " + a.name, + }); + input.checked = a.labels.includes(l.id); + wrap.append(document.createTextNode(l.word)); + input.onchange = async () => { + const next = clone(current), + r = next.artifacts.find((x) => x.id === a.id); + r.labels = input.checked + ? [...r.labels, l.id] + : r.labels.filter((id) => id !== l.id); + r.revision++; + r.modified = now(); + try { + await commit(next, "Labels saved."); + const row = [...$("recent").querySelectorAll("article")].find( + (row) => row.dataset.id === a.id, + ); + if (row) { + const focus = [...row.querySelectorAll("input")].find( + (el) => + el.getAttribute("aria-label") === l.word + " for " + a.name, + ); + focus?.focus(); + } else $("find").focus(); + } catch (e) { + input.checked = !input.checked; + announce(e.message); + } + }; + } + const exp = element("button", "Export " + a.name, menu, { type: "button" }); + exp.onclick = () => exportWork(a.id); + const duplicate = element("button", "Duplicate " + a.name, menu, { + type: "button", + }); + duplicate.onclick = async () => { + try { + const next = clone(current), + copy = clone(a); + copy.id = uuid(); + copy.name = [...(copy.name + " (copy)")].slice(0, 160).join(""); + copy.revision = 1; + copy.created = copy.modified = now(); + next.artifacts.push(copy); + await commit(next, "Separate copy saved."); + } catch (e) { + announce(e.message); + } + }; + const del = element("button", "Delete " + a.name, menu, { type: "button" }); + del.onclick = async () => { + if ( + !confirm( + "Before deleting “" + + a.name + + "”, use its Export action if you need a backup. Delete now? There is no trash.", + ) + ) + return; + try { + const next = clone(current); + next.artifacts = next.artifacts.filter((x) => x.id !== a.id); + await commit(next, "Work deleted. Restore only from a prior backup."); + $("find").focus(); + } catch (e) { + announce(e.message); + } + }; + } +} +let searchTimer; +$("find").oninput = () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(renderResults, 200); +}; +$("clear").onclick = () => { + selected = ""; + $("find").value = ""; + render(); + $("find").focus(); +}; +$("new-label").onsubmit = async (e) => { + e.preventDefault(); + const word = $("label-word").value.trim(); + if (!word) return; + try { + const next = clone(current); + next.labels.push({ id: uuid(), word }); + await commit(next, "Custom label created."); + $("new-label").reset(); + $("label-word").focus(); + } catch (e) { + announce(e.message); + } +}; +async function exportWork(id = null) { + try { + const latest = await store.read(); + const result = await exportArchive(latest, id); + download(result.data, result.name); + announce( + "Archive prepared; download started. Check Downloads or Files before relying on it.", + ); + } catch (e) { + announce("Export failed: " + e.message); + } +} +$("export-all").onclick = () => exportWork(); +const dialog = $("import-preview"); +function preview() { + $("import-content").replaceChildren(); + try { + plan = planImport(source, previewState, choices); + const s = plan.summary; + element( + "p", + "Schema v1 · " + + s.added + + " to add · " + + s.copied + + " copies · " + + s.skipped + + " skipped · " + + s.drafts + + " unfinished drafts. Imported practice is learner-produced.", + $("import-content"), + ); + if (source.persistence === "includes-uncommitted-draft") + element( + "p", + "This archive includes work that was not saved on its source device.", + $("import-content"), + ); + for (const issue of plan.issues) { + const wrapper = element( + "label", + (issue.name || issue.word) + + " · " + + (issue.type === "artifact" + ? "Existing work differs; it will not be overwritten." + : issue.type === "label" + ? "Label conflicts with “" + issue.existing + "”." + : "Missing label definition; choose a repair."), + $("import-content"), + ); + const select = element("select", "", wrapper); + const opts = + issue.type === "artifact" + ? [ + ["", "Choose (Skip is default)"], + ["skip", "Skip imported work"], + ["copy", "Keep imported as a separate copy"], + ] + : issue.type === "label" + ? [ + ["", "Choose label mapping"], + ["existing", "Use existing label"], + ["copy", "Create separate label and remap"], + ] + : [ + ["", "Choose repair"], + ["omit", "Omit this membership explicitly"], + ...previewState.labels.map((l) => [ + "map:" + l.id, + "Map to " + l.word, + ]), + ]; + for (const [value, text] of opts) { + element("option", text, select, { value }); + } + select.value = + choices[ + (issue.type === "artifact" ? "artifact:" : "label:") + issue.id + ] || ""; + select.onchange = () => { + choices[ + (issue.type === "artifact" ? "artifact:" : "label:") + issue.id + ] = select.value; + preview(); + $("import-content").querySelector("select")?.focus(); + }; + } + $("import-confirm").disabled = plan.issues.some( + (i) => i.type !== "artifact", + ); + $("import-status").textContent = plan.issues.length + ? "Review conflicts. Unselected artifact conflicts will be skipped." + : "Ready to import atomically. Existing work will be preserved."; + } catch (e) { + plan = null; + $("import-confirm").disabled = true; + $("import-status").textContent = e.message; + } +} +$("import-file").onchange = async () => { + const file = $("import-file").files[0]; + if (!file) return; + try { + if (file.size > 20 * 1024 * 1024) throw new Error("Input exceeds 20 MiB."); + source = await readArchive( + new Uint8Array(await file.arrayBuffer()), + file.name, + ); + previewState = await store.read(); + choices = {}; + preview(); + dialog.showModal(); + $("import-cancel").focus(); + } catch (e) { + announce("Import rejected; Library unchanged. " + e.message); + } finally { + $("import-file").value = ""; + } +}; +function close() { + dialog.close(); + source = null; + plan = null; + $("import-file").focus(); +} +$("import-cancel").onclick = close; +dialog.addEventListener("cancel", (e) => { + e.preventDefault(); + close(); +}); +$("import-confirm").onclick = async () => { + try { + const explicit = { ...choices }; + for (const i of plan.issues) + if (i.type === "artifact") explicit["artifact:" + i.id] = "skip"; + const final = planImport(source, previewState, explicit); + if (final.issues.length) throw new Error("Resolve label repairs first."); + const latest = await store.read(); + if (latest.revision !== previewState.revision) { + previewState = latest; + preview(); + throw new Error( + "Library changed since preview. Review the refreshed plan and confirm again.", + ); + } + current = await store.commit(final.revision, final.next); + render(); + const s = final.summary; + close(); + announce( + "Import committed: " + + s.added + + " added, " + + s.skipped + + " skipped. Verify restored content and labels.", + ); + } catch (e) { + $("import-status").textContent = "Import not committed. " + e.message; + } +}; +if (navigator.storage?.persisted) { + try { + const persisted = await navigator.storage.persisted(); + $("storage-info").textContent = persisted + ? "Browser reports persistent storage. Keep an external backup." + : "Browser storage is best effort. Keep an external backup."; + $("request-persistence").hidden = !navigator.storage.persist; + } catch {} +} +$("request-persistence").onclick = async () => { + try { + const granted = await navigator.storage.persist(); + $("storage-info").textContent = granted + ? "Persistence granted by browser; backup is still necessary." + : "Persistence not granted; work remains best effort. Export a backup."; + } catch (e) { + announce("Persistence request unavailable. Export a backup."); + } +}; +window.addEventListener("pageshow", () => load()); +await load(); diff --git a/docs/ai-literacy/filing/model.mjs b/docs/ai-literacy/filing/model.mjs new file mode 100644 index 0000000..9d3f3e3 --- /dev/null +++ b/docs/ai-literacy/filing/model.mjs @@ -0,0 +1,442 @@ +/* Creative Filing v1: local records only. Imported content is never HTML. */ +export const FORMAT = "pixie.creative-filing"; +export const LIMIT = { + artifacts: 100, + labels: 1000, + artifactBytes: 2 * 1024 * 1024, + bytes: 20 * 1024 * 1024, +}; +export const uuid = () => crypto.randomUUID(); +export const now = () => new Date().toISOString(); +export const clone = (value) => structuredClone(value); +export const bytes = (value) => new TextEncoder().encode(value); +export const jsonBytes = (value) => + bytes(JSON.stringify(value, null, 2) + "\n"); +export function fail(message) { + throw new Error(message); +} +export function shape(o, required, optional = []) { + if ( + !o || + typeof o !== "object" || + Array.isArray(o) || + required.some((k) => !Object.hasOwn(o, k)) || + Object.keys(o).some((k) => ![...required, ...optional].includes(k)) + ) + fail("Unsupported or incomplete record shape."); +} +export function string(v, max = 2 * 1024 * 1024, min = 0) { + if (typeof v !== "string" || [...v].length < min || [...v].length > max) + fail("Invalid text field."); +} +export function id(v) { + if ( + typeof v !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + v, + ) + ) + fail("Invalid UUID."); +} +export function timestamp(v) { + if ( + typeof v !== "string" || + !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(v) || + !Number.isFinite(Date.parse(v)) || + new Date(v).toISOString() !== v + ) + fail("Invalid UTC timestamp."); +} +const array = (v, max) => { + if (!Array.isArray(v) || v.length > max) + fail("Invalid array or record limit exceeded."); +}; +const unique = (values) => { + if (new Set(values).size !== values.length) fail("Duplicate identifiers."); +}; +const strings = (v) => { + array(v, 10000); + v.forEach((x) => string(x)); +}; +const integer = (v, min, max) => { + if (!Number.isSafeInteger(v) || v < min || v > max) fail("Invalid integer."); +}; +export function label(v) { + shape(v, ["id", "word"], ["color"]); + id(v.id); + string(v.word, 40, 1); + if (v.word !== v.word.trim() || /\p{Cc}/u.test(v.word)) + fail("Label must be trimmed."); + if (v.color !== undefined && !/^#[0-9a-f]{6}$/i.test(v.color)) + fail("Invalid label color."); + return v; +} +function criterion(c) { + shape(c, ["id", "text", "weight", "origin", "reason"]); + string(c.id, 160, 1); + string(c.text); + string(c.reason); + integer(c.weight, -10, 10); + if (c.weight === 0 || !["explicit", "implicit"].includes(c.origin)) + fail("Invalid criterion weight or origin."); +} +function snapshot(e) { + shape(e, [ + "id", + "seed", + "prompt", + "facts", + "response", + "criteria", + "perPerson", + "partyTotal", + "score", + ]); + string(e.id, 160, 1); + integer(e.seed, 0, 4294967295); + string(e.prompt); + strings(e.facts); + strings(e.response); + array(e.criteria, 3); + if (e.criteria.length !== 3) fail("Incomplete scenario criteria."); + e.criteria.forEach((c) => { + shape(c, ["text", "weight", "origin", "pass", "evidence"]); + string(c.text); + string(c.evidence); + integer(c.weight, -10, 10); + if ( + !c.weight || + !["explicit", "implicit"].includes(c.origin) || + typeof c.pass !== "boolean" + ) + fail("Invalid answer key."); + }); + integer(e.perPerson, 0, 1e9); + integer(e.partyTotal, 0, 2e9); + integer(e.score, -30, 30); + if ( + e.partyTotal !== 2 * e.perPerson || + e.score !== e.criteria.reduce((s, c) => s + (c.pass ? c.weight : 0), 0) + ) + fail("Scenario totals do not match its answer key."); +} +function attempt(a) { + shape(a, ["round", "exercise", "answers"], ["studyGuide", "nextPath"]); + integer(a.round, 1, 2); + snapshot(a.exercise); + array(a.answers, 4); + a.answers.forEach((h, i) => { + shape(h, ["step", "answer", "correct"]); + if (h.step !== i + 1 || typeof h.correct !== "boolean") + fail("Invalid answer order."); + string(h.answer, 80, 1); + if (h.step < 4 && !["PASS", "FAIL"].includes(h.answer)) + fail("Invalid answer."); + if ( + h.step === 4 && + (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(h.answer) || + !Number.isFinite(Number(h.answer))) + ) + fail("Invalid numeric answer."); + const expected = + h.step < 4 + ? a.exercise.criteria[h.step - 1].pass + ? "PASS" + : "FAIL" + : a.exercise.score; + if ( + h.correct !== + (h.step < 4 ? h.answer === expected : Number(h.answer) === expected) + ) + fail("Answer correctness disagrees with included key."); + }); + if (a.studyGuide !== undefined) strings(a.studyGuide); + if (a.nextPath !== undefined) { + shape(a.nextPath, ["label", "href"]); + string(a.nextPath.label, 200); + string(a.nextPath.href, 300); + if (!/^\.\.\/[a-z0-9/-]+\.html$/.test(a.nextPath.href)) + fail("Unsupported learning path."); + } +} +export function artifact(v, labels, { allowMissing = false } = {}) { + shape(v, [ + "id", + "name", + "exercise", + "labels", + "created", + "modified", + "content", + "schemaVersion", + "revision", + "provenance", + ]); + id(v.id); + if (v.schemaVersion !== 1) fail("Unsupported artifact schema version."); + integer(v.revision, 1, Number.MAX_SAFE_INTEGER); + string(v.name, 160, 1); + if (v.name !== v.name.trim()) fail("Name must be trimmed."); + shape(v.exercise, ["id", "title", "version"]); + Object.values(v.exercise).forEach((x) => string(x, 160, 1)); + if ( + !["rubric-writing", "synthetic-classroom", "legacy-markdown"].includes( + v.exercise.id, + ) || + v.exercise.version !== "1" + ) + fail("Unsupported exercise version."); + timestamp(v.created); + timestamp(v.modified); + if (v.modified < v.created) fail("Modified time precedes creation."); + array(v.labels, LIMIT.labels); + unique(v.labels); + v.labels.forEach((x) => { + id(x); + if (!allowMissing && !labels.some((l) => l.id === x)) + fail("Missing label definition: " + x); + }); + if (v.provenance !== null) { + shape(v.provenance, [ + "sourceFormat", + "sourceHash", + "importedAt", + "sourceArtifactId", + ]); + if ( + ![FORMAT, "legacy/markdown-v0"].includes(v.provenance.sourceFormat) || + !/^[a-f0-9]{64}$/.test(v.provenance.sourceHash) + ) + fail("Invalid provenance."); + timestamp(v.provenance.importedAt); + if (v.provenance.sourceArtifactId !== null) + id(v.provenance.sourceArtifactId); + } + const c = v.content; + if (c?.type === "legacy/markdown-v0") { + shape(c, ["type", "status", "text", "originalFilename"]); + if (c.status !== "draft" || v.exercise.id !== "legacy-markdown") + fail("Invalid legacy status."); + string(c.text); + string(c.originalFilename, 1024); + } else { + shape( + c, + [ + "type", + "taskPrompt", + "inputFacts", + "fictionalDeliverable", + "criteria", + "gradingResults", + "revisionNotes", + "status", + ], + ["seed", "scenarioId", "attempts"], + ); + if ( + c.type !== "practice/structured-v1" || + v.exercise.id === "legacy-markdown" || + !["draft", "complete"].includes(c.status) + ) + fail("Unsupported content."); + string(c.taskPrompt); + strings(c.inputFacts); + string(c.fictionalDeliverable); + array(c.criteria, 3); + c.criteria.forEach(criterion); + unique(c.criteria.map((x) => x.id)); + array(c.gradingResults, 3); + unique(c.gradingResults.map((x) => x.criterionId)); + c.gradingResults.forEach((r) => { + shape(r, ["criterionId", "outcome", "evidence", "points"]); + const cr = c.criteria.find((x) => x.id === r.criterionId); + if (!cr || !["PASS", "FAIL", "ungraded"].includes(r.outcome)) + fail("Invalid grading reference."); + string(r.evidence); + const expected = + r.outcome === "ungraded" ? null : r.outcome === "PASS" ? cr.weight : 0; + if (r.points !== expected) fail("Signed points disagree with outcome."); + }); + array(c.revisionNotes, 1000); + c.revisionNotes.forEach((r) => { + shape(r, ["before", "feedback", "after", "reason"]); + Object.values(r).forEach((x) => string(x)); + }); + c.revisionNotes + .filter((n) => n.feedback === "Local resume state: writer-state/v1") + .forEach((n) => { + const r = JSON.parse(n.after); + shape(r, ["drafts", "form", "feedback", "reviews", "answers", "score"]); + array(r.drafts, 3); + array(r.form, 4); + if (r.form.length !== 4) fail("Invalid unfinished writer state."); + r.form.forEach((v) => string(v, 1200)); + if (!["explicit", "implicit"].includes(r.form[2])) + fail("Invalid writer origin."); + string(r.feedback); + string(r.score, 80); + integer(r.reviews, 0, 2); + array(r.answers, 7); + r.answers.forEach((a) => { + shape(a, ["name", "value"]); + if ( + !/^answer-[1-7]$/.test(a.name) || + !["0", "1", "2"].includes(a.value) + ) + fail("Invalid lesson response."); + }); + unique(r.answers.map((a) => a.name)); + r.drafts.forEach((d, i) => { + shape(d, ["criterion", "weight", "origin", "reason"]); + criterion({ + id: "rubric-" + (i + 1), + text: d.criterion, + weight: d.weight, + origin: d.origin, + reason: d.reason, + }); + const cr = c.criteria[i]; + if ( + !cr || + cr.text !== d.criterion || + cr.weight !== d.weight || + cr.origin !== d.origin || + cr.reason !== d.reason + ) + fail("Writer drafts disagree with included criteria."); + }); + if (c.criteria.length !== r.drafts.length) + fail("Writer draft count mismatch."); + }); + if ( + (v.exercise.id === "synthetic-classroom") !== + (c.attempts !== undefined) + ) + fail("Exercise snapshot type mismatch."); + if (c.attempts !== undefined) { + integer(c.seed, 0, 4294967295); + string(c.scenarioId, 160, 1); + array(c.attempts, 2); + c.attempts.forEach((a, i) => { + attempt(a); + if (a.round !== i + 1 || a.exercise.seed !== c.seed) + fail("Invalid attempt sequence."); + }); + if (!c.attempts.length || c.scenarioId !== c.attempts.at(-1).exercise.id) + fail("Scenario identity mismatch."); + } else if (c.seed !== undefined || c.scenarioId !== undefined) + fail("Incomplete scenario snapshot."); + if ( + c.status === "complete" && + (!c.taskPrompt || + !c.inputFacts.length || + !c.fictionalDeliverable || + c.criteria.length !== 3 || + c.gradingResults.length !== 3 || + c.gradingResults.some((x) => x.outcome === "ungraded" || !x.evidence) || + c.attempts?.some((a) => a.answers.length !== 4)) + ) + fail("Complete work requires all evidence."); + } + if (jsonBytes(v).length > LIMIT.artifactBytes) + fail("Artifact exceeds 2 MiB."); + return v; +} +export function validate(state) { + shape(state, ["revision", "artifacts", "labels"]); + integer(state.revision, 0, Number.MAX_SAFE_INTEGER); + array(state.artifacts, LIMIT.artifacts); + array(state.labels, LIMIT.labels); + state.labels.forEach(label); + unique(state.labels.map((x) => x.id)); + unique(state.artifacts.map((x) => x.id)); + state.artifacts.forEach((a) => artifact(a, state.labels)); + return state; +} +export function initial() { + return { + revision: 0, + artifacts: [], + labels: ["Needs review", "In progress", "Reference", "Verified"].map( + (word, i) => ({ id: `00000000-0000-4000-8000-00000000000${i}`, word }), + ), + }; +} +export function create(exercise, content) { + const time = now(); + return { + id: uuid(), + name: (exercise.title + " " + new Date().toLocaleString()).slice(0, 160), + exercise, + labels: [], + created: time, + modified: time, + content, + schemaVersion: 1, + revision: 1, + provenance: null, + }; +} +export function find(state, query = "", selected = "") { + const norm = (s) => s.normalize("NFC").toLowerCase(); + const tokens = norm(query).trim().split(/\s+/).filter(Boolean); + return state.artifacts + .filter( + (a) => + (!selected || a.labels.includes(selected)) && + tokens.every((t) => + [ + a.name, + a.exercise.title, + ...a.labels.map( + (id) => state.labels.find((l) => l.id === id)?.word || "", + ), + ].some((s) => norm(s).includes(t)), + ), + ) + .sort( + (a, b) => + b.modified.localeCompare(a.modified) || a.id.localeCompare(b.id), + ); +} +function canonical(v) { + return Array.isArray(v) + ? v.map(canonical) + : v && typeof v === "object" + ? Object.fromEntries( + Object.keys(v) + .sort() + .map((k) => [k, canonical(v[k])]), + ) + : v; +} +export function logical(a) { + return JSON.stringify( + canonical([ + a.schemaVersion, + a.name, + a.exercise, + [...a.labels].sort(), + a.created, + a.modified, + a.content, + ]), + ); +} +export async function hash(value) { + return Array.from( + new Uint8Array(await crypto.subtle.digest("SHA-256", value)), + (x) => x.toString(16).padStart(2, "0"), + ).join(""); +} +export const safeName = (name) => + [ + ...name + .normalize("NFC") + .replace(/[\p{Cc}/\\:*?"<>|]/gu, "-") + .replace(/^[. ]+|[. ]+$/g, ""), + ] + .slice(0, 60) + .join("") + .replace(/[. ]+$/g, "") || "artifact"; diff --git a/docs/ai-literacy/filing/shared.mjs b/docs/ai-literacy/filing/shared.mjs new file mode 100644 index 0000000..5af2564 --- /dev/null +++ b/docs/ai-literacy/filing/shared.mjs @@ -0,0 +1,70 @@ +export function element(tag, text = "", parent, attributes = {}) { + const el = document.createElement(tag); + el.textContent = text; + for (const [k, v] of Object.entries(attributes)) el.setAttribute(k, v); + parent?.append(el); + return el; +} +export function download(data, name, type = "application/zip") { + const url = URL.createObjectURL(new Blob([data], { type })); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 60000); +} +const editing = new WeakMap(); +export function rename(record, button, save, announce) { + if (editing.has(button)) { + editing.get(button).focus(); + return; + } + const form = element("form", "", button.parentElement); + const label = element("label", "Name", form); + const input = element("input", "", label, { + type: "text", + required: "", + maxlength: "160", + }); + editing.set(button, input); + input.value = record.name; + const done = element("button", "Done", form, { type: "submit" }), + cancel = element("button", "Cancel", form, { type: "button" }); + button.hidden = true; + input.focus(); + input.select(); + const close = () => { + editing.delete(button); + form.remove(); + button.hidden = false; + button.focus(); + }; + cancel.onclick = close; + form.onkeydown = (e) => { + if (e.key === "Escape") { + e.preventDefault(); + close(); + } + }; + form.onsubmit = async (e) => { + e.preventDefault(); + const name = input.value.trim(); + if (!name) { + input.setCustomValidity("Enter a name."); + input.reportValidity(); + return; + } + input.setCustomValidity(""); + done.disabled = true; + try { + const message = await save(name); + close(); + announce(message || "Name updated."); + } catch (e) { + done.disabled = false; + announce(e.message); + button.hidden = false; + button.focus(); + } + }; +} diff --git a/docs/ai-literacy/filing/store.mjs b/docs/ai-literacy/filing/store.mjs new file mode 100644 index 0000000..ebf87d6 --- /dev/null +++ b/docs/ai-literacy/filing/store.mjs @@ -0,0 +1,153 @@ +import { initial, validate, clone, now, fail } from "./model.mjs"; +/* One versioned snapshot transaction keeps artifacts, labels and revision atomic. + No server fallback. Resolve writes only on transaction completion. */ +export function openStore(factory = globalThis.indexedDB) { + let database; + let opened; + function databaseConnection() { + if (opened) return opened; + opened = new Promise((resolve, reject) => { + if (!factory) { + reject(new Error("IndexedDB unavailable. Export the working draft.")); + return; + } + const req = factory.open("pixie-creative-filing", 1); + req.onupgradeneeded = () => req.result.createObjectStore("records"); + req.onerror = () => reject(req.error); + req.onblocked = () => + reject(new Error("Close other Training tabs to open storage.")); + req.onsuccess = () => { + database = req.result; + database.onversionchange = () => database.close(); + resolve(database); + }; + }); + opened.catch(() => { + opened = null; + }); + return opened; + } + let queue = Promise.resolve(); + async function transaction(change) { + const db = await databaseConnection(); + return new Promise((resolve, reject) => { + const tx = db.transaction("records", change ? "readwrite" : "readonly"); + const store = tx.objectStore("records"); + let result, error; + const req = store.get("library"); + req.onsuccess = () => { + try { + const current = validate(req.result || initial()); + result = change ? change(clone(current)) : clone(current); + if (change) { + validate(result); + store.put(result, "library"); + } + } catch (e) { + error = e; + tx.abort(); + } + }; + tx.oncomplete = () => resolve(clone(result)); + tx.onabort = () => + reject(error || tx.error || new Error("Storage transaction aborted.")); + tx.onerror = () => {}; + }); + } + function mutate(change) { + const run = queue.then(() => transaction(change)); + queue = run.catch(() => {}); + return run; + } + return { + read: () => transaction(), + commit: (expected, next) => + mutate((current) => { + if (current.revision !== expected) + fail( + "Library changed in another tab. Review again; your working draft is retained.", + ); + return { ...clone(next), revision: current.revision + 1 }; + }), + save: (draft, expected) => + mutate((current) => { + const old = current.artifacts.find((a) => a.id === draft.id); + if ((old?.revision || 0) !== expected) + fail( + "Save conflict: another tab changed this work. Export your draft or keep a separate copy.", + ); + const saved = clone(draft); + saved.revision = expected + 1; + saved.modified = old + ? new Date( + Math.max(Date.now(), Date.parse(old.modified)), + ).toISOString() + : saved.modified; + const i = current.artifacts.findIndex((a) => a.id === draft.id); + if (i < 0) current.artifacts.push(saved); + else current.artifacts[i] = saved; + current.revision++; + return current; + }), + }; +} +export class Draft { + constructor(store, record, revision, onState = () => {}) { + this.store = store; + this.record = clone(record); + this.revision = revision; + this.onState = onState; + this.generation = 0; + this.durable = revision ? clone(record) : null; + this.running = null; + this.dirty = false; + } + edit(record) { + this.record = clone(record); + this.record.modified = new Date( + Math.max(Date.now(), Date.parse(record.created)), + ).toISOString(); + this.generation++; + this.dirty = true; + this.onState("Unsaved changes"); + clearTimeout(this.timer); + this.timer = setTimeout(() => this.flush(), 500); + if (!this.maxTimer) this.maxTimer = setTimeout(() => this.flush(), 2000); + } + async flush() { + clearTimeout(this.timer); + clearTimeout(this.maxTimer); + this.maxTimer = null; + if (this.running) + return this.running.then(() => (this.dirty ? this.flush() : !this.error)); + if (!this.dirty) return !this.error; + const generation = this.generation, + snapshot = clone(this.record); + this.onState("Saving"); + this.running = this.store + .save(snapshot, this.revision) + .then((state) => { + const saved = state.artifacts.find((a) => a.id === snapshot.id); + this.revision = saved.revision; + this.durable = clone(saved); + this.error = null; + if (this.generation === generation) { + this.record = clone(saved); + this.dirty = false; + this.onState("Saved on this device"); + } + return true; + }) + .catch((e) => { + this.error = e; + this.onState("Could not save", e.message); + return false; + }) + .finally(() => { + this.running = null; + }); + const ok = await this.running; + if (ok && this.dirty) return this.flush(); + return ok; + } +} diff --git a/docs/ai-literacy/filing/vendor/LICENSE.fflate b/docs/ai-literacy/filing/vendor/LICENSE.fflate new file mode 100644 index 0000000..bd36874 --- /dev/null +++ b/docs/ai-literacy/filing/vendor/LICENSE.fflate @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Arjun Barrett + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/docs/ai-literacy/filing/vendor/README.md b/docs/ai-literacy/filing/vendor/README.md new file mode 100644 index 0000000..b5c5b13 --- /dev/null +++ b/docs/ai-literacy/filing/vendor/README.md @@ -0,0 +1,16 @@ +# ZIP dependency review + +Creative Filing bundles **fflate 0.8.3** (`esm/browser.js`, unmodified, MIT). +Source: https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz +Upstream: https://github.com/101arrowz/fflate + +- npm tarball integrity: `sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==` +- Vendored module SHA-256: `b7ca4450b19559a1d50eb381adcee94b82449674be4cd17789d9beba7e6122a1` +- License retained in `LICENSE.fflate`. +- Review: 2026-11-08 UTC. An isolated installation of this exact version and `npm audit --audit-level=low` reported zero known advisories. That is an advisory check, not a security certification. + +No compatible ZIP implementation existed in Training. The selected dependency supplies synchronous streaming **Inflate** only in production. It avoids a new build pipeline, CDN, worker, native filesystem API or service. The complete upstream ESM file is retained for reproducible provenance; other exports are not used by production code. Tests use its independent ZIP writer to exercise deflate interoperability. + +`archive.mjs` owns the strict ZIP framing/central-directory checks, path/member allowlist, CRC and SHA-256 checks, declared-size limits and actual output bounds. It rejects encrypted, symlink, multivolume, ZIP64, overlapping, unlisted, duplicate and unsupported members before reading payloads. Deflate input is fed in 1 KiB chunks; output callbacks enforce the declared per-member and total bounds. No extraction, HTML rendering, fetch or arbitrary imports occur. The writer uses STORE for predictably bounded output. Keep this wrapper and adversarial tests when updating the dependency; do not replace it with an unbounded `unzipSync` call. + +This is an explicitly reviewed vendored runtime dependency, not a package-manager dependency; the Training lockfile is unchanged. Review parser/dependency behavior again before a production release or upgrade. diff --git a/docs/ai-literacy/filing/vendor/fflate.mjs b/docs/ai-literacy/filing/vendor/fflate.mjs new file mode 100644 index 0000000..82af0fd --- /dev/null +++ b/docs/ai-literacy/filing/vendor/fflate.mjs @@ -0,0 +1,2692 @@ +// DEFLATE is a complex format; to read this code, you should probably check the RFC first: +// https://tools.ietf.org/html/rfc1951 +// You may also wish to take a look at the guide I made about this program: +// https://gist.github.com/101arrowz/253f31eb5abc3d9275ab943003ffecad +// Some of the following code is similar to that of UZIP.js: +// https://github.com/photopea/UZIP.js +// However, the vast majority of the codebase has diverged from UZIP.js to increase performance and reduce bundle size. +// Sometimes 0 will appear where -1 would be more appropriate. This is because using a uint +// is better for memory in most engines (I *think*). +var ch2 = {}; +var wk = (function (c, id, msg, transfer, cb) { + var w = new Worker(ch2[id] || (ch2[id] = URL.createObjectURL(new Blob([ + c + ';addEventListener("error",function(e){e=e.error;postMessage({$e$:[e.message,e.code,e.stack]})})' + ], { type: 'text/javascript' })))); + w.onmessage = function (e) { + var d = e.data, ed = d.$e$; + if (ed) { + var err = new Error(ed[0]); + err['code'] = ed[1]; + err.stack = ed[2]; + cb(err, null); + } + else + cb(null, d); + }; + w.postMessage(msg, transfer); + return w; +}); + +// aliases for shorter compressed code (most minifers don't do this) +var u8 = Uint8Array, u16 = Uint16Array, i32 = Int32Array; +// fixed length extra bits +var fleb = new u8([0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0, /* unused */ 0, 0, /* impossible */ 0]); +// fixed distance extra bits +var fdeb = new u8([0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, /* unused */ 0, 0]); +// code length index map +var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]); +// get base, reverse index map from extra bits +var freb = function (eb, start) { + var b = new u16(31); + for (var i = 0; i < 31; ++i) { + b[i] = start += 1 << eb[i - 1]; + } + // numbers here are at max 18 bits + var r = new i32(b[30]); + for (var i = 1; i < 30; ++i) { + for (var j = b[i]; j < b[i + 1]; ++j) { + r[j] = ((j - b[i]) << 5) | i; + } + } + return { b: b, r: r }; +}; +var _a = freb(fleb, 2), fl = _a.b, revfl = _a.r; +// we can ignore the fact that the other numbers are wrong; they never happen anyway +fl[28] = 258, revfl[258] = 28; +var _b = freb(fdeb, 0), fd = _b.b, revfd = _b.r; +// map of value to reverse (assuming 16 bits) +var rev = new u16(32768); +for (var i = 0; i < 32768; ++i) { + // reverse table algorithm from SO + var x = ((i & 0xAAAA) >> 1) | ((i & 0x5555) << 1); + x = ((x & 0xCCCC) >> 2) | ((x & 0x3333) << 2); + x = ((x & 0xF0F0) >> 4) | ((x & 0x0F0F) << 4); + rev[i] = (((x & 0xFF00) >> 8) | ((x & 0x00FF) << 8)) >> 1; +} +// create huffman tree from u8 "map": index -> code length for code index +// mb (max bits) must be at most 15 +// TODO: optimize/split up? +var hMap = (function (cd, mb, r) { + var s = cd.length; + // index + var i = 0; + // u16 "map": index -> # of codes with bit length = index + var l = new u16(mb); + // length of cd must be 288 (total # of codes) + for (; i < s; ++i) { + if (cd[i]) + ++l[cd[i] - 1]; + } + // u16 "map": index -> minimum code for bit length = index + var le = new u16(mb); + for (i = 1; i < mb; ++i) { + le[i] = (le[i - 1] + l[i - 1]) << 1; + } + var co; + if (r) { + // u16 "map": index -> number of actual bits, symbol for code + co = new u16(1 << mb); + // bits to remove for reverser + var rvb = 15 - mb; + for (i = 0; i < s; ++i) { + // ignore 0 lengths + if (cd[i]) { + // num encoding both symbol and bits read + var sv = (i << 4) | cd[i]; + // free bits + var r_1 = mb - cd[i]; + // start value + var v = le[cd[i] - 1]++ << r_1; + // m is end value + for (var m = v | ((1 << r_1) - 1); v <= m; ++v) { + // every 16 bit value starting with the code yields the same result + co[rev[v] >> rvb] = sv; + } + } + } + } + else { + co = new u16(s); + for (i = 0; i < s; ++i) { + if (cd[i]) { + co[i] = rev[le[cd[i] - 1]++] >> (15 - cd[i]); + } + } + } + return co; +}); +// fixed length tree +var flt = new u8(288); +for (var i = 0; i < 144; ++i) + flt[i] = 8; +for (var i = 144; i < 256; ++i) + flt[i] = 9; +for (var i = 256; i < 280; ++i) + flt[i] = 7; +for (var i = 280; i < 288; ++i) + flt[i] = 8; +// fixed distance tree +var fdt = new u8(32); +for (var i = 0; i < 32; ++i) + fdt[i] = 5; +// fixed length map +var flm = /*#__PURE__*/ hMap(flt, 9, 0), flrm = /*#__PURE__*/ hMap(flt, 9, 1); +// fixed distance map +var fdm = /*#__PURE__*/ hMap(fdt, 5, 0), fdrm = /*#__PURE__*/ hMap(fdt, 5, 1); +// find max of array +var max = function (a) { + var m = a[0]; + for (var i = 1; i < a.length; ++i) { + if (a[i] > m) + m = a[i]; + } + return m; +}; +// read d, starting at bit p and mask with m +var bits = function (d, p, m) { + var o = (p / 8) | 0; + return ((d[o] | (d[o + 1] << 8)) >> (p & 7)) & m; +}; +// read d, starting at bit p continuing for at least 16 bits +var bits16 = function (d, p) { + var o = (p / 8) | 0; + return ((d[o] | (d[o + 1] << 8) | (d[o + 2] << 16)) >> (p & 7)); +}; +// get end of byte +var shft = function (p) { return ((p + 7) / 8) | 0; }; +// typed array slice - allows garbage collector to free original reference, +// while being more compatible than .slice +var slc = function (v, s, e) { + if (s == null || s < 0) + s = 0; + if (e == null || e > v.length) + e = v.length; + // can't use .constructor in case user-supplied + return new u8(v.subarray(s, e)); +}; +/** + * Codes for errors generated within this library + */ +export var FlateErrorCode = { + UnexpectedEOF: 0, + InvalidBlockType: 1, + InvalidLengthLiteral: 2, + InvalidDistance: 3, + StreamFinished: 4, + NoStreamHandler: 5, + InvalidHeader: 6, + NoCallback: 7, + InvalidUTF8: 8, + ExtraFieldTooLong: 9, + InvalidDate: 10, + FilenameTooLong: 11, + StreamFinishing: 12, + InvalidZipData: 13, + UnknownCompressionMethod: 14 +}; +// error codes +var ec = [ + 'unexpected EOF', + 'invalid block type', + 'invalid length/literal', + 'invalid distance', + 'stream finished', + 'no stream handler', + , // determined by compression function + 'no callback', + 'invalid UTF-8 data', + 'extra field too long', + 'date not in range 1980-2099', + 'filename too long', + 'stream finishing', + 'invalid zip data' + // determined by unknown compression method +]; +; +var err = function (ind, msg, nt) { + var e = new Error(msg || ec[ind]); + e.code = ind; + if (Error.captureStackTrace) + Error.captureStackTrace(e, err); + if (!nt) + throw e; + return e; +}; +// expands raw DEFLATE data +var inflt = function (dat, st, buf, dict) { + // source length dict length + var sl = dat.length, dl = dict ? dict.length : 0; + if (!sl || st.f && !st.l) + return buf || new u8(0); + var noBuf = !buf; + // have to estimate size + var resize = noBuf || st.i != 2; + // no state + var noSt = st.i; + // Assumes roughly 33% compression ratio average + if (noBuf) + buf = new u8(sl * 3); + // ensure buffer can fit at least l elements + var cbuf = function (l) { + var bl = buf.length; + // need to increase size to fit + if (l > bl) { + // Double or set to necessary, whichever is greater + var nbuf = new u8(Math.max(bl * 2, l)); + nbuf.set(buf); + buf = nbuf; + } + }; + // last chunk bitpos bytes + var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n; + // total bits + var tbts = sl * 8; + do { + if (!lm) { + // BFINAL - this is only 1 when last chunk is next + final = bits(dat, pos, 1); + // type: 0 = no compression, 1 = fixed huffman, 2 = dynamic huffman + var type = bits(dat, pos + 1, 3); + pos += 3; + if (!type) { + // go to end of byte boundary + var s = shft(pos) + 4, l = dat[s - 4] | (dat[s - 3] << 8), t = s + l; + if (t > sl) { + if (noSt) + err(0); + break; + } + // ensure size + if (resize) + cbuf(bt + l); + // Copy over uncompressed data + buf.set(dat.subarray(s, t), bt); + // Get new bitpos, update byte count + st.b = bt += l, st.p = pos = t * 8, st.f = final; + continue; + } + else if (type == 1) + lm = flrm, dm = fdrm, lbt = 9, dbt = 5; + else if (type == 2) { + // literal lengths + var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4; + var tl = hLit + bits(dat, pos + 5, 31) + 1; + pos += 14; + // length+distance tree + var ldt = new u8(tl); + // code length tree + var clt = new u8(19); + for (var i = 0; i < hcLen; ++i) { + // use index map to get real code + clt[clim[i]] = bits(dat, pos + i * 3, 7); + } + pos += hcLen * 3; + // code lengths bits + var clb = max(clt), clbmsk = (1 << clb) - 1; + // code lengths map + var clm = hMap(clt, clb, 1); + for (var i = 0; i < tl;) { + var r = clm[bits(dat, pos, clbmsk)]; + // bits read + pos += r & 15; + // symbol + var s = r >> 4; + // code length to copy + if (s < 16) { + ldt[i++] = s; + } + else { + // copy count + var c = 0, n = 0; + if (s == 16) + n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1]; + else if (s == 17) + n = 3 + bits(dat, pos, 7), pos += 3; + else if (s == 18) + n = 11 + bits(dat, pos, 127), pos += 7; + while (n--) + ldt[i++] = c; + } + } + // length tree distance tree + var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit); + // max length bits + lbt = max(lt); + // max dist bits + dbt = max(dt); + lm = hMap(lt, lbt, 1); + dm = hMap(dt, dbt, 1); + } + else + err(1); + if (pos > tbts) { + if (noSt) + err(0); + break; + } + } + // Make sure the buffer can hold this + the largest possible addition + // Maximum chunk size (practically, theoretically infinite) is 2^17 + if (resize) + cbuf(bt + 131072); + var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1; + var lpos = pos; + for (;; lpos = pos) { + // bits read, code + var c = lm[bits16(dat, pos) & lms], sym = c >> 4; + pos += c & 15; + if (pos > tbts) { + if (noSt) + err(0); + break; + } + if (!c) + err(2); + if (sym < 256) + buf[bt++] = sym; + else if (sym == 256) { + lpos = pos, lm = null; + break; + } + else { + var add = sym - 254; + // no extra bits needed if less + if (sym > 264) { + // index + var i = sym - 257, b = fleb[i]; + add = bits(dat, pos, (1 << b) - 1) + fl[i]; + pos += b; + } + // dist + var d = dm[bits16(dat, pos) & dms], dsym = d >> 4; + if (!d) + err(3); + pos += d & 15; + var dt = fd[dsym]; + if (dsym > 3) { + var b = fdeb[dsym]; + dt += bits16(dat, pos) & (1 << b) - 1, pos += b; + } + if (pos > tbts) { + if (noSt) + err(0); + break; + } + if (resize) + cbuf(bt + 131072); + var end = bt + add; + if (bt < dt) { + var shift = dl - dt, dend = Math.min(dt, end); + if (shift + bt < 0) + err(3); + for (; bt < dend; ++bt) + buf[bt] = dict[shift + bt]; + } + for (; bt < end; ++bt) + buf[bt] = buf[bt - dt]; + } + } + st.l = lm, st.p = lpos, st.b = bt, st.f = final; + if (lm) + final = 1, st.m = lbt, st.d = dm, st.n = dbt; + } while (!final); + // don't reallocate for streams or user buffers + return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt); +}; +// starting at p, write the minimum number of bits that can hold v to d +var wbits = function (d, p, v) { + v <<= p & 7; + var o = (p / 8) | 0; + d[o] |= v; + d[o + 1] |= v >> 8; +}; +// starting at p, write the minimum number of bits (>8) that can hold v to d +var wbits16 = function (d, p, v) { + v <<= p & 7; + var o = (p / 8) | 0; + d[o] |= v; + d[o + 1] |= v >> 8; + d[o + 2] |= v >> 16; +}; +// creates code lengths from a frequency table +var hTree = function (d, mb) { + // Need extra info to make a tree + var t = []; + for (var i = 0; i < d.length; ++i) { + if (d[i]) + t.push({ s: i, f: d[i] }); + } + var s = t.length; + var t2 = t.slice(); + if (!s) + return { t: et, l: 0 }; + if (s == 1) { + var v = new u8(t[0].s + 1); + v[t[0].s] = 1; + return { t: v, l: 1 }; + } + t.sort(function (a, b) { return a.f - b.f; }); + // after i2 reaches last ind, will be stopped + // freq must be greater than largest possible number of symbols + t.push({ s: -1, f: 25001 }); + var l = t[0], r = t[1], i0 = 0, i1 = 1, i2 = 2; + t[0] = { s: -1, f: l.f + r.f, l: l, r: r }; + // efficient algorithm from UZIP.js + // i0 is lookbehind, i2 is lookahead - after processing two low-freq + // symbols that combined have high freq, will start processing i2 (high-freq, + // non-composite) symbols instead + // see https://reddit.com/r/photopea/comments/ikekht/uzipjs_questions/ + while (i1 != s - 1) { + l = t[t[i0].f < t[i2].f ? i0++ : i2++]; + r = t[i0 != i1 && t[i0].f < t[i2].f ? i0++ : i2++]; + t[i1++] = { s: -1, f: l.f + r.f, l: l, r: r }; + } + var maxSym = t2[0].s; + for (var i = 1; i < s; ++i) { + if (t2[i].s > maxSym) + maxSym = t2[i].s; + } + // code lengths + var tr = new u16(maxSym + 1); + // max bits in tree + var mbt = ln(t[i1 - 1], tr, 0); + if (mbt > mb) { + // more algorithms from UZIP.js + // TODO: find out how this code works (debt) + // ind debt + var i = 0, dt = 0; + // left cost + var lft = mbt - mb, cst = 1 << lft; + t2.sort(function (a, b) { return tr[b.s] - tr[a.s] || a.f - b.f; }); + for (; i < s; ++i) { + var i2_1 = t2[i].s; + if (tr[i2_1] > mb) { + dt += cst - (1 << (mbt - tr[i2_1])); + tr[i2_1] = mb; + } + else + break; + } + dt >>= lft; + while (dt > 0) { + var i2_2 = t2[i].s; + if (tr[i2_2] < mb) + dt -= 1 << (mb - tr[i2_2]++ - 1); + else + ++i; + } + for (; i >= 0 && dt; --i) { + var i2_3 = t2[i].s; + if (tr[i2_3] == mb) { + --tr[i2_3]; + ++dt; + } + } + mbt = mb; + } + return { t: new u8(tr), l: mbt }; +}; +// get the max length and assign length codes +var ln = function (n, l, d) { + return n.s == -1 + ? Math.max(ln(n.l, l, d + 1), ln(n.r, l, d + 1)) + : (l[n.s] = d); +}; +// length codes generation +var lc = function (c) { + var s = c.length; + // Note that the semicolon was intentional + while (s && !c[--s]) + ; + var cl = new u16(++s); + // ind num streak + var cli = 0, cln = c[0], cls = 1; + var w = function (v) { cl[cli++] = v; }; + for (var i = 1; i <= s; ++i) { + if (c[i] == cln && i != s) + ++cls; + else { + if (!cln && cls > 2) { + for (; cls > 138; cls -= 138) + w(32754); + if (cls > 2) { + w(cls > 10 ? ((cls - 11) << 5) | 28690 : ((cls - 3) << 5) | 12305); + cls = 0; + } + } + else if (cls > 3) { + w(cln), --cls; + for (; cls > 6; cls -= 6) + w(8304); + if (cls > 2) + w(((cls - 3) << 5) | 8208), cls = 0; + } + while (cls--) + w(cln); + cls = 1; + cln = c[i]; + } + } + return { c: cl.subarray(0, cli), n: s }; +}; +// calculate the length of output from tree, code lengths +var clen = function (cf, cl) { + var l = 0; + for (var i = 0; i < cl.length; ++i) + l += cf[i] * cl[i]; + return l; +}; +// writes a fixed block +// returns the new bit pos +var wfblk = function (out, pos, dat) { + // no need to write 00 as type: TypedArray defaults to 0 + var s = dat.length; + var o = shft(pos + 2); + out[o] = s & 255; + out[o + 1] = s >> 8; + out[o + 2] = out[o] ^ 255; + out[o + 3] = out[o + 1] ^ 255; + for (var i = 0; i < s; ++i) + out[o + i + 4] = dat[i]; + return (o + 4 + s) * 8; +}; +// writes a block +var wblk = function (dat, out, final, syms, lf, df, eb, li, bs, bl, p) { + wbits(out, p++, final); + ++lf[256]; + var _a = hTree(lf, 15), dlt = _a.t, mlb = _a.l; + var _b = hTree(df, 15), ddt = _b.t, mdb = _b.l; + var _c = lc(dlt), lclt = _c.c, nlc = _c.n; + var _d = lc(ddt), lcdt = _d.c, ndc = _d.n; + var lcfreq = new u16(19); + for (var i = 0; i < lclt.length; ++i) + ++lcfreq[lclt[i] & 31]; + for (var i = 0; i < lcdt.length; ++i) + ++lcfreq[lcdt[i] & 31]; + var _e = hTree(lcfreq, 7), lct = _e.t, mlcb = _e.l; + var nlcc = 19; + for (; nlcc > 4 && !lct[clim[nlcc - 1]]; --nlcc) + ; + var flen = (bl + 5) << 3; + var ftlen = clen(lf, flt) + clen(df, fdt) + eb; + var dtlen = clen(lf, dlt) + clen(df, ddt) + eb + 14 + 3 * nlcc + clen(lcfreq, lct) + 2 * lcfreq[16] + 3 * lcfreq[17] + 7 * lcfreq[18]; + if (bs >= 0 && flen <= ftlen && flen <= dtlen) + return wfblk(out, p, dat.subarray(bs, bs + bl)); + var lm, ll, dm, dl; + wbits(out, p, 1 + (dtlen < ftlen)), p += 2; + if (dtlen < ftlen) { + lm = hMap(dlt, mlb, 0), ll = dlt, dm = hMap(ddt, mdb, 0), dl = ddt; + var llm = hMap(lct, mlcb, 0); + wbits(out, p, nlc - 257); + wbits(out, p + 5, ndc - 1); + wbits(out, p + 10, nlcc - 4); + p += 14; + for (var i = 0; i < nlcc; ++i) + wbits(out, p + 3 * i, lct[clim[i]]); + p += 3 * nlcc; + var lcts = [lclt, lcdt]; + for (var it = 0; it < 2; ++it) { + var clct = lcts[it]; + for (var i = 0; i < clct.length; ++i) { + var len = clct[i] & 31; + wbits(out, p, llm[len]), p += lct[len]; + if (len > 15) + wbits(out, p, (clct[i] >> 5) & 127), p += clct[i] >> 12; + } + } + } + else { + lm = flm, ll = flt, dm = fdm, dl = fdt; + } + for (var i = 0; i < li; ++i) { + var sym = syms[i]; + if (sym > 255) { + var len = (sym >> 18) & 31; + wbits16(out, p, lm[len + 257]), p += ll[len + 257]; + if (len > 7) + wbits(out, p, (sym >> 23) & 31), p += fleb[len]; + var dst = sym & 31; + wbits16(out, p, dm[dst]), p += dl[dst]; + if (dst > 3) + wbits16(out, p, (sym >> 5) & 8191), p += fdeb[dst]; + } + else { + wbits16(out, p, lm[sym]), p += ll[sym]; + } + } + wbits16(out, p, lm[256]); + return p + ll[256]; +}; +// deflate options (nice << 13) | chain +var deo = /*#__PURE__*/ new i32([65540, 131080, 131088, 131104, 262176, 1048704, 1048832, 2114560, 2117632]); +// empty +var et = /*#__PURE__*/ new u8(0); +// compresses data into a raw DEFLATE buffer +var dflt = function (dat, lvl, plvl, pre, post, st) { + var s = st.z || dat.length; + var o = new u8(pre + s + 5 * (1 + Math.ceil(s / 7000)) + post); + // writing to this writes to the output buffer + var w = o.subarray(pre, o.length - post); + var lst = st.l; + var pos = (st.r || 0) & 7; + if (lvl) { + if (pos) + w[0] = st.r >> 3; + var opt = deo[lvl - 1]; + var n = opt >> 13, c = opt & 8191; + var msk_1 = (1 << plvl) - 1; + // prev 2-byte val map curr 2-byte val map + var prev = st.p || new u16(32768), head = st.h || new u16(msk_1 + 1); + var bs1_1 = Math.ceil(plvl / 3), bs2_1 = 2 * bs1_1; + var hsh = function (i) { return (dat[i] ^ (dat[i + 1] << bs1_1) ^ (dat[i + 2] << bs2_1)) & msk_1; }; + // 24576 is an arbitrary number of maximum symbols per block + // 424 buffer for last block + var syms = new i32(25000); + // length/literal freq distance freq + var lf = new u16(288), df = new u16(32); + // l/lcnt exbits index l/lind waitdx blkpos + var lc_1 = 0, eb = 0, i = st.i || 0, li = 0, wi = st.w || 0, bs = 0; + for (; i + 2 < s; ++i) { + // hash value + var hv = hsh(i); + // index mod 32768 previous index mod + var imod = i & 32767, pimod = head[hv]; + prev[imod] = pimod; + head[hv] = imod; + // We always should modify head and prev, but only add symbols if + // this data is not yet processed ("wait" for wait index) + if (wi <= i) { + // bytes remaining + var rem = s - i; + if ((lc_1 > 7000 || li > 24576) && (rem > 423 || !lst)) { + pos = wblk(dat, w, 0, syms, lf, df, eb, li, bs, i - bs, pos); + li = lc_1 = eb = 0, bs = i; + for (var j = 0; j < 286; ++j) + lf[j] = 0; + for (var j = 0; j < 30; ++j) + df[j] = 0; + } + // len dist chain + var l = 2, d = 0, ch_1 = c, dif = imod - pimod & 32767; + if (rem > 2 && hv == hsh(i - dif)) { + var maxn = Math.min(n, rem) - 1; + var maxd = Math.min(32767, i); + // max possible length + // not capped at dif because decompressors implement "rolling" index population + var ml = Math.min(258, rem); + while (dif <= maxd && --ch_1 && imod != pimod) { + if (dat[i + l] == dat[i + l - dif]) { + var nl = 0; + for (; nl < ml && dat[i + nl] == dat[i + nl - dif]; ++nl) + ; + if (nl > l) { + l = nl, d = dif; + // break out early when we reach "nice" (we are satisfied enough) + if (nl > maxn) + break; + // now, find the rarest 2-byte sequence within this + // length of literals and search for that instead. + // Much faster than just using the start + var mmd = Math.min(dif, nl - 2); + var md = 0; + for (var j = 0; j < mmd; ++j) { + var ti = i - dif + j & 32767; + var pti = prev[ti]; + var cd = ti - pti & 32767; + if (cd > md) + md = cd, pimod = ti; + } + } + } + // check the previous match + imod = pimod, pimod = prev[imod]; + dif += imod - pimod & 32767; + } + } + // d will be nonzero only when a match was found + if (d) { + // store both dist and len data in one int32 + // Make sure this is recognized as a len/dist with 28th bit (2^28) + syms[li++] = 268435456 | (revfl[l] << 18) | revfd[d]; + var lin = revfl[l] & 31, din = revfd[d] & 31; + eb += fleb[lin] + fdeb[din]; + ++lf[257 + lin]; + ++df[din]; + wi = i + l; + ++lc_1; + } + else { + syms[li++] = dat[i]; + ++lf[dat[i]]; + } + } + } + for (i = Math.max(i, wi); i < s; ++i) { + syms[li++] = dat[i]; + ++lf[dat[i]]; + } + pos = wblk(dat, w, lst, syms, lf, df, eb, li, bs, i - bs, pos); + if (!lst) { + st.r = (pos & 7) | w[(pos / 8) | 0] << 3; + // shft(pos) now 1 less if pos & 7 != 0 + pos -= 7; + st.h = head, st.p = prev, st.i = i, st.w = wi; + } + } + else { + for (var i = st.w || 0; i < s + lst; i += 65535) { + // end + var e = i + 65535; + if (e >= s) { + // write final block + w[(pos / 8) | 0] = lst; + e = s; + } + pos = wfblk(w, pos + 1, dat.subarray(i, e)); + } + st.i = s; + } + return slc(o, 0, pre + shft(pos) + post); +}; +// CRC32 table +var crct = /*#__PURE__*/ (function () { + var t = new Int32Array(256); + for (var i = 0; i < 256; ++i) { + var c = i, k = 9; + while (--k) + c = ((c & 1) && -306674912) ^ (c >>> 1); + t[i] = c; + } + return t; +})(); +// CRC32 +var crc = function () { + var c = -1; + return { + p: function (d) { + // closures have awful performance + var cr = c; + for (var i = 0; i < d.length; ++i) + cr = crct[(cr & 255) ^ d[i]] ^ (cr >>> 8); + c = cr; + }, + d: function () { return ~c; } + }; +}; +// Adler32 +var adler = function () { + var a = 1, b = 0; + return { + p: function (d) { + // closures have awful performance + var n = a, m = b; + var l = d.length | 0; + for (var i = 0; i != l;) { + var e = Math.min(i + 2655, l); + for (; i < e; ++i) + m += n += d[i]; + n = (n & 65535) + 15 * (n >> 16), m = (m & 65535) + 15 * (m >> 16); + } + a = n, b = m; + }, + d: function () { + a %= 65521, b %= 65521; + return (a & 255) << 24 | (a & 0xFF00) << 8 | (b & 255) << 8 | (b >> 8); + } + }; +}; +; +// deflate with opts +var dopt = function (dat, opt, pre, post, st) { + if (!st) { + st = { l: 1 }; + if (opt.dictionary) { + var dict = opt.dictionary.subarray(-32768); + var newDat = new u8(dict.length + dat.length); + newDat.set(dict); + newDat.set(dat, dict.length); + dat = newDat; + st.w = dict.length; + } + } + return dflt(dat, opt.level == null ? 6 : opt.level, opt.mem == null ? (st.l ? Math.ceil(Math.max(8, Math.min(13, Math.log(dat.length))) * 1.5) : 20) : (12 + opt.mem), pre, post, st); +}; +// Walmart object spread +var mrg = function (a, b) { + var o = {}; + for (var k in a) + o[k] = a[k]; + for (var k in b) + o[k] = b[k]; + return o; +}; +// worker clone +// This is possibly the craziest part of the entire codebase, despite how simple it may seem. +// The only parameter to this function is a closure that returns an array of variables outside of the function scope. +// We're going to try to figure out the variable names used in the closure as strings because that is crucial for workerization. +// We will return an object mapping of true variable name to value (basically, the current scope as a JS object). +// The reason we can't just use the original variable names is minifiers mangling the toplevel scope. +// This took me three weeks to figure out how to do. +var wcln = function (fn, fnStr, td) { + var dt = fn(); + var st = fn.toString(); + var ks = st.slice(st.indexOf('[') + 1, st.lastIndexOf(']')).replace(/\s+/g, '').split(','); + for (var i = 0; i < dt.length; ++i) { + var v = dt[i], k = ks[i]; + if (typeof v == 'function') { + fnStr += ';' + k + '='; + var st_1 = v.toString(); + if (v.prototype) { + // for global objects + if (st_1.indexOf('[native code]') != -1) { + var spInd = st_1.indexOf(' ', 8) + 1; + fnStr += st_1.slice(spInd, st_1.indexOf('(', spInd)); + } + else { + fnStr += st_1; + for (var t in v.prototype) + fnStr += ';' + k + '.prototype.' + t + '=' + v.prototype[t].toString(); + } + } + else + fnStr += st_1; + } + else + td[k] = v; + } + return fnStr; +}; +var ch = []; +// clone bufs +var cbfs = function (v) { + var tl = []; + for (var k in v) { + if (v[k].buffer) { + tl.push((v[k] = new v[k].constructor(v[k])).buffer); + } + } + return tl; +}; +// use a worker to execute code +var wrkr = function (fns, init, id, cb) { + if (!ch[id]) { + var fnStr = '', td_1 = {}, m = fns.length - 1; + for (var i = 0; i < m; ++i) + fnStr = wcln(fns[i], fnStr, td_1); + ch[id] = { c: wcln(fns[m], fnStr, td_1), e: td_1 }; + } + var td = mrg({}, ch[id].e); + return wk(ch[id].c + ';onmessage=function(e){for(var k in e.data)self[k]=e.data[k];onmessage=' + init.toString() + '}', id, td, cbfs(td), cb); +}; +// base async inflate fn +var bInflt = function () { return [u8, u16, i32, fleb, fdeb, clim, fl, fd, flrm, fdrm, rev, ec, hMap, max, bits, bits16, shft, slc, err, inflt, inflateSync, pbf, gopt]; }; +var bDflt = function () { return [u8, u16, i32, fleb, fdeb, clim, revfl, revfd, flm, flt, fdm, fdt, rev, deo, et, hMap, wbits, wbits16, hTree, ln, lc, clen, wfblk, wblk, shft, slc, dflt, dopt, deflateSync, pbf]; }; +// gzip extra +var gze = function () { return [gzh, gzhl, wbytes, crc, crct]; }; +// gunzip extra +var guze = function () { return [gzs, gzl]; }; +// zlib extra +var zle = function () { return [zlh, wbytes, adler]; }; +// unzlib extra +var zule = function () { return [zls]; }; +// post buf +var pbf = function (msg) { return postMessage(msg, [msg.buffer]); }; +// get opts +var gopt = function (o) { return o && { + out: o.size && new u8(o.size), + dictionary: o.dictionary +}; }; +// async helper +var cbify = function (dat, opts, fns, init, id, cb) { + var w = wrkr(fns, init, id, function (err, dat) { + w.terminate(); + cb(err, dat); + }); + w.postMessage([dat, opts], opts.consume ? [dat.buffer] : []); + return function () { w.terminate(); }; +}; +// auto stream +var astrm = function (strm) { + strm.ondata = function (dat, final) { return postMessage([dat, final], [dat.buffer]); }; + return function (ev) { + if (ev.data[0]) { + strm.push(ev.data[0], ev.data[1]); + postMessage([ev.data[0].length]); + } + else + strm.flush(ev.data[1]); + }; +}; +// async stream attach +var astrmify = function (fns, strm, opts, init, id, flush, ext) { + var t; + var w = wrkr(fns, init, id, function (err, dat) { + if (err) + w.terminate(), strm.ondata.call(strm, err); + else if (!Array.isArray(dat)) + ext(dat); + else if (dat.length == 1) { + strm.queuedSize -= dat[0]; + if (strm.ondrain) + strm.ondrain(dat[0]); + } + else { + if (dat[1]) + w.terminate(); + strm.ondata.call(strm, err, dat[0], dat[1]); + } + }); + w.postMessage(opts); + strm.queuedSize = 0; + strm.push = function (d, f) { + if (!strm.ondata) + err(5); + if (t) + strm.ondata(err(4, 0, 1), null, !!f); + strm.queuedSize += d.length; + // can fail for cross-realm Uint8Array, but ok - only a small performance penalty + w.postMessage([d, t = f], d.buffer instanceof ArrayBuffer ? [d.buffer] : []); + }; + strm.terminate = function () { w.terminate(); }; + if (flush) { + strm.flush = function (sync) { w.postMessage([0, sync]); }; + } +}; +// read 2 bytes +var b2 = function (d, b) { return d[b] | (d[b + 1] << 8); }; +// read 4 bytes +var b4 = function (d, b) { return (d[b] | (d[b + 1] << 8) | (d[b + 2] << 16) | (d[b + 3] << 24)) >>> 0; }; +// read 8 bytes +var b8 = function (d, b) { return b4(d, b) + (b4(d, b + 4) * 4294967296); }; +// write bytes +var wbytes = function (d, b, v) { + for (; v; ++b) + d[b] = v, v >>>= 8; +}; +// gzip header +var gzh = function (c, o) { + var fn = o.filename; + c[0] = 31, c[1] = 139, c[2] = 8, c[8] = o.level < 2 ? 4 : o.level == 9 ? 2 : 0, c[9] = 3; // assume Unix + if (o.mtime != 0) + wbytes(c, 4, Math.floor(new Date(o.mtime || Date.now()) / 1000)); + if (fn) { + c[3] = 8; + for (var i = 0; i <= fn.length; ++i) + c[i + 10] = fn.charCodeAt(i); + } +}; +// gzip footer: -8 to -4 = CRC, -4 to -0 is length +// gzip start +var gzs = function (d) { + if (d[0] != 31 || d[1] != 139 || d[2] != 8) + err(6, 'invalid gzip data'); + var flg = d[3]; + var st = 10; + if (flg & 4) + st += (d[10] | d[11] << 8) + 2; + for (var zs = (flg >> 3 & 1) + (flg >> 4 & 1); zs > 0; zs -= !d[st++]) + ; + return st + (flg & 2); +}; +// gzip length +var gzl = function (d) { + var l = d.length; + return (d[l - 4] | d[l - 3] << 8 | d[l - 2] << 16 | d[l - 1] << 24) >>> 0; +}; +// gzip header length +var gzhl = function (o) { return 10 + (o.filename ? o.filename.length + 1 : 0); }; +// zlib header +var zlh = function (c, o) { + var lv = o.level, fl = lv == 0 ? 0 : lv < 6 ? 1 : lv == 9 ? 3 : 2; + c[0] = 120, c[1] = (fl << 6) | (o.dictionary && 32); + c[1] |= 31 - ((c[0] << 8) | c[1]) % 31; + if (o.dictionary) { + var h = adler(); + h.p(o.dictionary); + wbytes(c, 2, h.d()); + } +}; +// zlib start +var zls = function (d, dict) { + if ((d[0] & 15) != 8 || (d[0] >> 4) > 7 || ((d[0] << 8 | d[1]) % 31)) + err(6, 'invalid zlib data'); + if ((d[1] >> 5 & 1) == +!dict) + err(6, 'invalid zlib data: ' + (d[1] & 32 ? 'need' : 'unexpected') + ' dictionary'); + return (d[1] >> 3 & 4) + 2; +}; +function StrmOpt(opts, cb) { + if (typeof opts == 'function') + cb = opts, opts = {}; + this.ondata = cb; + return opts; +} +/** + * Streaming DEFLATE compression + */ +var Deflate = /*#__PURE__*/ (function () { + function Deflate(opts, cb) { + if (typeof opts == 'function') + cb = opts, opts = {}; + this.ondata = cb; + this.o = opts || {}; + this.s = { l: 0, i: 32768, w: 32768, z: 32768 }; + // Buffer length must always be 0 mod 32768 for index calculations to be correct when modifying head and prev + // 98304 = 32768 (lookback) + 65536 (common chunk size) + this.b = new u8(98304); + if (this.o.dictionary) { + var dict = this.o.dictionary.subarray(-32768); + this.b.set(dict, 32768 - dict.length); + this.s.i = 32768 - dict.length; + } + } + Deflate.prototype.p = function (c, f) { + this.ondata(dopt(c, this.o, 0, 0, this.s), f); + }; + /** + * Pushes a chunk to be deflated + * @param chunk The chunk to push + * @param final Whether this is the last chunk + */ + Deflate.prototype.push = function (chunk, final) { + if (!this.ondata) + err(5); + if (this.s.l) + err(4); + var endLen = chunk.length + this.s.z; + if (endLen > this.b.length) { + if (endLen > 2 * this.b.length - 32768) { + var newBuf = new u8(endLen & -32768); + newBuf.set(this.b.subarray(0, this.s.z)); + this.b = newBuf; + } + var split = this.b.length - this.s.z; + this.b.set(chunk.subarray(0, split), this.s.z); + this.s.z = this.b.length; + this.p(this.b, false); + this.b.set(this.b.subarray(-32768)); + this.b.set(chunk.subarray(split), 32768); + this.s.z = chunk.length - split + 32768; + this.s.i = 32766, this.s.w = 32768; + } + else { + this.b.set(chunk, this.s.z); + this.s.z += chunk.length; + } + this.s.l = final & 1; + if (this.s.z > this.s.w + 8191 || final) { + this.p(this.b, final || false); + this.s.w = this.s.i, this.s.i -= 2; + } + if (final) { + // cleanup unneeded buffers/state to reduce memory usage + this.s = this.o = {}; + this.b = et; + } + }; + /** + * Flushes buffered uncompressed data. Useful to immediately retrieve the + * deflated output for small inputs. + * @param sync Whether to flush to a byte boundary. A sync flush takes 4-5 + * extra bytes, but guarantees all pushed data is immediately + * decompressible. A separate DEFLATE stream may be concatenated + * with the current output after a sync flush. + */ + Deflate.prototype.flush = function (sync) { + if (!this.ondata) + err(5); + if (this.s.l) + err(4); + this.p(this.b, false); + this.s.w = this.s.i, this.s.i -= 2; + // could technically skip writing the type-0 block for (this.s.r & 7) == 0, + // but the deterministic trailer (00 00 FF FF) is useful in some situations + if (sync) { + var c = new u8(6); + c[0] = this.s.r >> 3; + // write empty, non-final type-0 block + var ep = wfblk(c, this.s.r, et); + this.s.r = 0; + this.ondata(c.subarray(0, ep >> 3), false); + } + }; + return Deflate; +}()); +export { Deflate }; +/** + * Asynchronous streaming DEFLATE compression + */ +var AsyncDeflate = /*#__PURE__*/ (function () { + function AsyncDeflate(opts, cb) { + astrmify([ + bDflt, + function () { return [astrm, Deflate]; } + ], this, StrmOpt.call(this, opts, cb), function (ev) { + var strm = new Deflate(ev.data); + onmessage = astrm(strm); + }, 6, 1); + } + return AsyncDeflate; +}()); +export { AsyncDeflate }; +export function deflate(data, opts, cb) { + if (!cb) + cb = opts, opts = {}; + if (typeof cb != 'function') + err(7); + return cbify(data, opts, [ + bDflt, + ], function (ev) { return pbf(deflateSync(ev.data[0], ev.data[1])); }, 0, cb); +} +/** + * Compresses data with DEFLATE without any wrapper + * @param data The data to compress + * @param opts The compression options + * @returns The deflated version of the data + */ +export function deflateSync(data, opts) { + return dopt(data, opts || {}, 0, 0); +} +/** + * Streaming DEFLATE decompression + */ +var Inflate = /*#__PURE__*/ (function () { + function Inflate(opts, cb) { + // no StrmOpt here to avoid adding to workerizer + if (typeof opts == 'function') + cb = opts, opts = {}; + this.ondata = cb; + var dict = opts && opts.dictionary && opts.dictionary.subarray(-32768); + this.s = { i: 0, b: dict ? dict.length : 0 }; + this.o = new u8(32768); + this.p = new u8(0); + if (dict) + this.o.set(dict); + } + Inflate.prototype.e = function (c) { + if (!this.ondata) + err(5); + if (this.d) + err(4); + if (!this.p.length) + this.p = c; + else if (c.length) { + var n = new u8(this.p.length + c.length); + n.set(this.p), n.set(c, this.p.length), this.p = n; + } + }; + Inflate.prototype.c = function (final) { + this.s.i = +(this.d = final || false); + var bts = this.s.b; + var dt = inflt(this.p, this.s, this.o); + this.ondata(slc(dt, bts, this.s.b), this.d); + this.o = slc(dt, this.s.b - 32768), this.s.b = this.o.length; + this.p = slc(this.p, (this.s.p / 8) | 0), this.s.p &= 7; + }; + /** + * Pushes a chunk to be inflated + * @param chunk The chunk to push + * @param final Whether this is the final chunk + */ + Inflate.prototype.push = function (chunk, final) { + this.e(chunk), this.c(final); + }; + return Inflate; +}()); +export { Inflate }; +/** + * Asynchronous streaming DEFLATE decompression + */ +var AsyncInflate = /*#__PURE__*/ (function () { + function AsyncInflate(opts, cb) { + astrmify([ + bInflt, + function () { return [astrm, Inflate]; } + ], this, StrmOpt.call(this, opts, cb), function (ev) { + var strm = new Inflate(ev.data); + onmessage = astrm(strm); + }, 7, 0); + } + return AsyncInflate; +}()); +export { AsyncInflate }; +export function inflate(data, opts, cb) { + if (!cb) + cb = opts, opts = {}; + if (typeof cb != 'function') + err(7); + return cbify(data, opts, [ + bInflt + ], function (ev) { return pbf(inflateSync(ev.data[0], gopt(ev.data[1]))); }, 1, cb); +} +export function inflateSync(data, opts) { + return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary); +} +// before you yell at me for not just using extends, my reason is that TS inheritance is hard to workerize. +/** + * Streaming GZIP compression + */ +var Gzip = /*#__PURE__*/ (function () { + function Gzip(opts, cb) { + this.c = crc(); + this.l = 0; + this.v = 1; + Deflate.call(this, opts, cb); + } + /** + * Pushes a chunk to be GZIPped + * @param chunk The chunk to push + * @param final Whether this is the last chunk + */ + Gzip.prototype.push = function (chunk, final) { + this.c.p(chunk); + this.l += chunk.length; + Deflate.prototype.push.call(this, chunk, final); + }; + Gzip.prototype.p = function (c, f) { + var raw = dopt(c, this.o, this.v && gzhl(this.o), f && 8, this.s); + if (this.v) + gzh(raw, this.o), this.v = 0; + if (f) + wbytes(raw, raw.length - 8, this.c.d()), wbytes(raw, raw.length - 4, this.l); + this.ondata(raw, f); + }; + /** + * Flushes buffered uncompressed data. Useful to immediately retrieve the + * GZIPped output for small inputs. + * @param sync Whether to flush to a byte boundary. A sync flush takes 4-5 + * extra bytes, but guarantees all pushed data is immediately + * decompressible. + */ + Gzip.prototype.flush = function (sync) { + Deflate.prototype.flush.call(this, sync); + }; + return Gzip; +}()); +export { Gzip }; +/** + * Asynchronous streaming GZIP compression + */ +var AsyncGzip = /*#__PURE__*/ (function () { + function AsyncGzip(opts, cb) { + astrmify([ + bDflt, + gze, + function () { return [astrm, Deflate, Gzip]; } + ], this, StrmOpt.call(this, opts, cb), function (ev) { + var strm = new Gzip(ev.data); + onmessage = astrm(strm); + }, 8, 1); + } + return AsyncGzip; +}()); +export { AsyncGzip }; +export function gzip(data, opts, cb) { + if (!cb) + cb = opts, opts = {}; + if (typeof cb != 'function') + err(7); + return cbify(data, opts, [ + bDflt, + gze, + function () { return [gzipSync]; } + ], function (ev) { return pbf(gzipSync(ev.data[0], ev.data[1])); }, 2, cb); +} +/** + * Compresses data with GZIP + * @param data The data to compress + * @param opts The compression options + * @returns The gzipped version of the data + */ +export function gzipSync(data, opts) { + if (!opts) + opts = {}; + var c = crc(), l = data.length; + c.p(data); + var d = dopt(data, opts, gzhl(opts), 8), s = d.length; + return gzh(d, opts), wbytes(d, s - 8, c.d()), wbytes(d, s - 4, l), d; +} +/** + * Streaming single or multi-member GZIP decompression + */ +var Gunzip = /*#__PURE__*/ (function () { + function Gunzip(opts, cb) { + this.v = 1; + this.r = 0; + Inflate.call(this, opts, cb); + } + /** + * Pushes a chunk to be GUNZIPped + * @param chunk The chunk to push + * @param final Whether this is the last chunk + */ + Gunzip.prototype.push = function (chunk, final) { + Inflate.prototype.e.call(this, chunk); + this.r += chunk.length; + if (this.v) { + var p = this.p.subarray(this.v - 1); + var s = p.length > 3 ? gzs(p) : 4; + if (s > p.length) { + if (!final) + return; + } + else if (this.v > 1 && this.onmember) { + this.onmember(this.r - p.length); + } + this.p = p.subarray(s), this.v = 0; + } + // necessary to prevent TS from using the closure value + // This allows for workerization to function correctly + Inflate.prototype.c.call(this, 0); + // process concatenated GZIP + if (this.s.f && !this.s.l) { + this.v = shft(this.s.p) + 9; + this.s = { i: 0 }; + this.o = new u8(0); + this.push(new u8(0), final); + } + else if (final) { + Inflate.prototype.c.call(this, final); + } + }; + return Gunzip; +}()); +export { Gunzip }; +/** + * Asynchronous streaming single or multi-member GZIP decompression + */ +var AsyncGunzip = /*#__PURE__*/ (function () { + function AsyncGunzip(opts, cb) { + var _this = this; + astrmify([ + bInflt, + guze, + function () { return [astrm, Inflate, Gunzip]; } + ], this, StrmOpt.call(this, opts, cb), function (ev) { + var strm = new Gunzip(ev.data); + strm.onmember = function (offset) { return postMessage(offset); }; + onmessage = astrm(strm); + }, 9, 0, function (offset) { return _this.onmember && _this.onmember(offset); }); + } + return AsyncGunzip; +}()); +export { AsyncGunzip }; +export function gunzip(data, opts, cb) { + if (!cb) + cb = opts, opts = {}; + if (typeof cb != 'function') + err(7); + return cbify(data, opts, [ + bInflt, + guze, + function () { return [gunzipSync]; } + ], function (ev) { return pbf(gunzipSync(ev.data[0], ev.data[1])); }, 3, cb); +} +export function gunzipSync(data, opts) { + var st = gzs(data); + if (st + 8 > data.length) + err(6, 'invalid gzip data'); + return inflt(data.subarray(st, -8), { i: 2 }, opts && opts.out || new u8(gzl(data)), opts && opts.dictionary); +} +/** + * Streaming Zlib compression + */ +var Zlib = /*#__PURE__*/ (function () { + function Zlib(opts, cb) { + this.c = adler(); + this.v = 1; + Deflate.call(this, opts, cb); + } + /** + * Pushes a chunk to be zlibbed + * @param chunk The chunk to push + * @param final Whether this is the last chunk + */ + Zlib.prototype.push = function (chunk, final) { + this.c.p(chunk); + Deflate.prototype.push.call(this, chunk, final); + }; + Zlib.prototype.p = function (c, f) { + var raw = dopt(c, this.o, this.v && (this.o.dictionary ? 6 : 2), f && 4, this.s); + if (this.v) + zlh(raw, this.o), this.v = 0; + if (f) + wbytes(raw, raw.length - 4, this.c.d()); + this.ondata(raw, f); + }; + /** + * Flushes buffered uncompressed data. Useful to immediately retrieve the + * zlibbed output for small inputs. + * @param sync Whether to flush to a byte boundary. A sync flush takes 4-5 + * extra bytes, but guarantees all pushed data is immediately + * decompressible. + */ + Zlib.prototype.flush = function (sync) { + Deflate.prototype.flush.call(this, sync); + }; + return Zlib; +}()); +export { Zlib }; +/** + * Asynchronous streaming Zlib compression + */ +var AsyncZlib = /*#__PURE__*/ (function () { + function AsyncZlib(opts, cb) { + astrmify([ + bDflt, + zle, + function () { return [astrm, Deflate, Zlib]; } + ], this, StrmOpt.call(this, opts, cb), function (ev) { + var strm = new Zlib(ev.data); + onmessage = astrm(strm); + }, 10, 1); + } + return AsyncZlib; +}()); +export { AsyncZlib }; +export function zlib(data, opts, cb) { + if (!cb) + cb = opts, opts = {}; + if (typeof cb != 'function') + err(7); + return cbify(data, opts, [ + bDflt, + zle, + function () { return [zlibSync]; } + ], function (ev) { return pbf(zlibSync(ev.data[0], ev.data[1])); }, 4, cb); +} +/** + * Compress data with Zlib + * @param data The data to compress + * @param opts The compression options + * @returns The zlib-compressed version of the data + */ +export function zlibSync(data, opts) { + if (!opts) + opts = {}; + var a = adler(); + a.p(data); + var d = dopt(data, opts, opts.dictionary ? 6 : 2, 4); + return zlh(d, opts), wbytes(d, d.length - 4, a.d()), d; +} +/** + * Streaming Zlib decompression + */ +var Unzlib = /*#__PURE__*/ (function () { + function Unzlib(opts, cb) { + Inflate.call(this, opts, cb); + this.v = opts && opts.dictionary ? 2 : 1; + } + /** + * Pushes a chunk to be unzlibbed + * @param chunk The chunk to push + * @param final Whether this is the last chunk + */ + Unzlib.prototype.push = function (chunk, final) { + Inflate.prototype.e.call(this, chunk); + if (this.v) { + if (this.p.length < 6 && !final) + return; + this.p = this.p.subarray(zls(this.p, this.v - 1)), this.v = 0; + } + if (final) { + if (this.p.length < 4) + err(6, 'invalid zlib data'); + this.p = this.p.subarray(0, -4); + } + // necessary to prevent TS from using the closure value + // This allows for workerization to function correctly + Inflate.prototype.c.call(this, final); + }; + return Unzlib; +}()); +export { Unzlib }; +/** + * Asynchronous streaming Zlib decompression + */ +var AsyncUnzlib = /*#__PURE__*/ (function () { + function AsyncUnzlib(opts, cb) { + astrmify([ + bInflt, + zule, + function () { return [astrm, Inflate, Unzlib]; } + ], this, StrmOpt.call(this, opts, cb), function (ev) { + var strm = new Unzlib(ev.data); + onmessage = astrm(strm); + }, 11, 0); + } + return AsyncUnzlib; +}()); +export { AsyncUnzlib }; +export function unzlib(data, opts, cb) { + if (!cb) + cb = opts, opts = {}; + if (typeof cb != 'function') + err(7); + return cbify(data, opts, [ + bInflt, + zule, + function () { return [unzlibSync]; } + ], function (ev) { return pbf(unzlibSync(ev.data[0], gopt(ev.data[1]))); }, 5, cb); +} +export function unzlibSync(data, opts) { + return inflt(data.subarray(zls(data, opts && opts.dictionary), -4), { i: 2 }, opts && opts.out, opts && opts.dictionary); +} +// Default algorithm for compression (used because having a known output size allows faster decompression) +export { gzip as compress, AsyncGzip as AsyncCompress }; +export { gzipSync as compressSync, Gzip as Compress }; +/** + * Streaming GZIP, Zlib, or raw DEFLATE decompression + */ +var Decompress = /*#__PURE__*/ (function () { + function Decompress(opts, cb) { + this.o = StrmOpt.call(this, opts, cb) || {}; + this.G = Gunzip; + this.I = Inflate; + this.Z = Unzlib; + } + // init substream + // overriden by AsyncDecompress + Decompress.prototype.i = function () { + var _this = this; + this.s.ondata = function (dat, final) { + _this.ondata(dat, final); + }; + }; + /** + * Pushes a chunk to be decompressed + * @param chunk The chunk to push + * @param final Whether this is the last chunk + */ + Decompress.prototype.push = function (chunk, final) { + if (!this.ondata) + err(5); + if (!this.s) { + if (this.p && this.p.length) { + var n = new u8(this.p.length + chunk.length); + n.set(this.p), n.set(chunk, this.p.length); + } + else + this.p = chunk; + if (this.p.length > 2) { + this.s = (this.p[0] == 31 && this.p[1] == 139 && this.p[2] == 8) + ? new this.G(this.o) + : ((this.p[0] & 15) != 8 || (this.p[0] >> 4) > 7 || ((this.p[0] << 8 | this.p[1]) % 31)) + ? new this.I(this.o) + : new this.Z(this.o); + this.i(); + this.s.push(this.p, final); + this.p = null; + } + } + else + this.s.push(chunk, final); + }; + return Decompress; +}()); +export { Decompress }; +/** + * Asynchronous streaming GZIP, Zlib, or raw DEFLATE decompression + */ +var AsyncDecompress = /*#__PURE__*/ (function () { + function AsyncDecompress(opts, cb) { + Decompress.call(this, opts, cb); + this.queuedSize = 0; + this.G = AsyncGunzip; + this.I = AsyncInflate; + this.Z = AsyncUnzlib; + } + AsyncDecompress.prototype.i = function () { + var _this = this; + this.s.ondata = function (err, dat, final) { + _this.ondata(err, dat, final); + }; + this.s.ondrain = function (size) { + _this.queuedSize -= size; + if (_this.ondrain) + _this.ondrain(size); + }; + }; + /** + * Pushes a chunk to be decompressed + * @param chunk The chunk to push + * @param final Whether this is the last chunk + */ + AsyncDecompress.prototype.push = function (chunk, final) { + this.queuedSize += chunk.length; + Decompress.prototype.push.call(this, chunk, final); + }; + return AsyncDecompress; +}()); +export { AsyncDecompress }; +export function decompress(data, opts, cb) { + if (!cb) + cb = opts, opts = {}; + if (typeof cb != 'function') + err(7); + return (data[0] == 31 && data[1] == 139 && data[2] == 8) + ? gunzip(data, opts, cb) + : ((data[0] & 15) != 8 || (data[0] >> 4) > 7 || ((data[0] << 8 | data[1]) % 31)) + ? inflate(data, opts, cb) + : unzlib(data, opts, cb); +} +/** + * Expands compressed GZIP, Zlib, or raw DEFLATE data, automatically detecting the format + * @param data The data to decompress + * @param opts The decompression options + * @returns The decompressed version of the data + */ +export function decompressSync(data, opts) { + return (data[0] == 31 && data[1] == 139 && data[2] == 8) + ? gunzipSync(data, opts) + : ((data[0] & 15) != 8 || (data[0] >> 4) > 7 || ((data[0] << 8 | data[1]) % 31)) + ? inflateSync(data, opts) + : unzlibSync(data, opts); +} +// flatten a directory structure +var fltn = function (d, p, t, o) { + for (var k in d) { + var val = d[k], n = p + k, op = o; + if (Array.isArray(val)) + op = mrg(o, val[1]), val = val[0]; + if (ArrayBuffer.isView(val)) + t[n] = [val, op]; + else { + t[n += '/'] = [new u8(0), op]; + fltn(val, n, t, o); + } + } +}; +// text encoder +var te = typeof TextEncoder != 'undefined' && /*#__PURE__*/ new TextEncoder(); +// text decoder +var td = typeof TextDecoder != 'undefined' && /*#__PURE__*/ new TextDecoder(); +// text decoder stream +var tds = 0; +try { + td.decode(et, { stream: true }); + tds = 1; +} +catch (e) { } +// decode UTF8 +var dutf8 = function (d) { + for (var r = '', i = 0;;) { + var c = d[i++]; + var eb = (c > 127) + (c > 223) + (c > 239); + if (i + eb > d.length) + return { s: r, r: slc(d, i - 1) }; + if (!eb) + r += String.fromCharCode(c); + else if (eb == 3) { + c = ((c & 15) << 18 | (d[i++] & 63) << 12 | (d[i++] & 63) << 6 | (d[i++] & 63)) - 65536, + r += String.fromCharCode(55296 | (c >> 10), 56320 | (c & 1023)); + } + else if (eb & 1) + r += String.fromCharCode((c & 31) << 6 | (d[i++] & 63)); + else + r += String.fromCharCode((c & 15) << 12 | (d[i++] & 63) << 6 | (d[i++] & 63)); + } +}; +/** + * Streaming UTF-8 decoding + */ +var DecodeUTF8 = /*#__PURE__*/ (function () { + /** + * Creates a UTF-8 decoding stream + * @param cb The callback to call whenever data is decoded + */ + function DecodeUTF8(cb) { + this.ondata = cb; + if (tds) + this.t = new TextDecoder(); + else + this.p = et; + } + /** + * Pushes a chunk to be decoded from UTF-8 binary + * @param chunk The chunk to push + * @param final Whether this is the last chunk + */ + DecodeUTF8.prototype.push = function (chunk, final) { + if (!this.ondata) + err(5); + final = !!final; + if (this.t) { + this.ondata(this.t.decode(chunk, { stream: true }), final); + if (final) { + if (this.t.decode().length) + err(8); + this.t = null; + } + return; + } + if (!this.p) + err(4); + var dat = new u8(this.p.length + chunk.length); + dat.set(this.p); + dat.set(chunk, this.p.length); + var _a = dutf8(dat), s = _a.s, r = _a.r; + if (final) { + if (r.length) + err(8); + this.p = null; + } + else + this.p = r; + this.ondata(s, final); + }; + return DecodeUTF8; +}()); +export { DecodeUTF8 }; +/** + * Streaming UTF-8 encoding + */ +var EncodeUTF8 = /*#__PURE__*/ (function () { + /** + * Creates a UTF-8 decoding stream + * @param cb The callback to call whenever data is encoded + */ + function EncodeUTF8(cb) { + this.ondata = cb; + } + /** + * Pushes a chunk to be encoded to UTF-8 + * @param chunk The string data to push + * @param final Whether this is the last chunk + */ + EncodeUTF8.prototype.push = function (chunk, final) { + if (!this.ondata) + err(5); + if (this.d) + err(4); + this.ondata(strToU8(chunk), this.d = final || false); + }; + return EncodeUTF8; +}()); +export { EncodeUTF8 }; +/** + * Converts a string into a Uint8Array for use with compression/decompression methods + * @param str The string to encode + * @param latin1 Whether or not to interpret the data as Latin-1. This should + * not need to be true unless decoding a binary string. + * @returns The string encoded in UTF-8/Latin-1 binary + */ +export function strToU8(str, latin1) { + if (latin1) { + var ar_1 = new u8(str.length); + for (var i = 0; i < str.length; ++i) + ar_1[i] = str.charCodeAt(i); + return ar_1; + } + if (te) + return te.encode(str); + var l = str.length; + var ar = new u8(str.length + (str.length >> 1)); + var ai = 0; + var w = function (v) { ar[ai++] = v; }; + for (var i = 0; i < l; ++i) { + if (ai + 5 > ar.length) { + var n = new u8(ai + 8 + ((l - i) << 1)); + n.set(ar); + ar = n; + } + var c = str.charCodeAt(i); + if (c < 128 || latin1) + w(c); + else if (c < 2048) + w(192 | (c >> 6)), w(128 | (c & 63)); + else if (c > 55295 && c < 57344) + c = 65536 + (c & 1023 << 10) | (str.charCodeAt(++i) & 1023), + w(240 | (c >> 18)), w(128 | ((c >> 12) & 63)), w(128 | ((c >> 6) & 63)), w(128 | (c & 63)); + else + w(224 | (c >> 12)), w(128 | ((c >> 6) & 63)), w(128 | (c & 63)); + } + return slc(ar, 0, ai); +} +/** + * Converts a Uint8Array to a string + * @param dat The data to decode to string + * @param latin1 Whether or not to interpret the data as Latin-1. This should + * not need to be true unless encoding to binary string. + * @returns The original UTF-8/Latin-1 string + */ +export function strFromU8(dat, latin1) { + if (latin1) { + var r = ''; + for (var i = 0; i < dat.length; i += 16384) + r += String.fromCharCode.apply(null, dat.subarray(i, i + 16384)); + return r; + } + else if (td) { + return td.decode(dat); + } + else { + var _a = dutf8(dat), s = _a.s, r = _a.r; + if (r.length) + err(8); + return s; + } +} +; +// deflate bit flag +var dbf = function (l) { return l == 1 ? 3 : l < 6 ? 2 : l == 9 ? 1 : 0; }; +// skip local zip header +var slzh = function (d, b) { return b + 30 + b2(d, b + 26) + b2(d, b + 28); }; +// read zip header +var zh = function (d, b, z) { + var fnl = b2(d, b + 28), efl = b2(d, b + 30), fn = strFromU8(d.subarray(b + 46, b + 46 + fnl), !(b2(d, b + 8) & 2048)), es = b + 46 + fnl; + var _a = z64hs(d, es, efl, z, b4(d, b + 20), b4(d, b + 24), b4(d, b + 42)), sc = _a[0], su = _a[1], off = _a[2]; + return [b2(d, b + 10), sc, su, fn, es + efl + b2(d, b + 32), off]; +}; +// read zip64 header sizes +var z64hs = function (d, b, l, z, sc, su, off) { + var nsc = sc == 4294967295, nsu = su == 4294967295, noff = off == 4294967295, e = b + l; + var nf = nsc + nsu + noff; + if (z && nf) { + for (; b + 4 < e; b += 4 + b2(d, b + 2)) { + if (b2(d, b) == 1) { + return [ + nsc ? b8(d, b + 4 + 8 * nsu) : sc, + nsu ? b8(d, b + 4) : su, + noff ? b8(d, b + 4 + 8 * (nsu + nsc)) : off, + 1 + ]; + } + } + // z == 2 for unknown whether or not zip64 + if (z < 2) + err(13); + } + return [sc, su, off, 0]; +}; +// extra field length +var exfl = function (ex) { + var le = 0; + if (ex) { + for (var k in ex) { + var l = ex[k].length; + if (l > 65535) + err(9); + le += l + 4; + } + } + return le; +}; +// write zip header +var wzh = function (d, b, f, fn, u, c, ce, co) { + var fl = fn.length, ex = f.extra, col = co && co.length; + var exl = exfl(ex); + wbytes(d, b, ce != null ? 0x2014B50 : 0x4034B50), b += 4; + if (ce != null) + d[b++] = 20, d[b++] = f.os; + d[b] = 20, b += 2; // spec compliance? what's that? + d[b++] = (f.flag << 1) | (c < 0 && 8), d[b++] = u && 8; + d[b++] = f.compression & 255, d[b++] = f.compression >> 8; + var dt = new Date(f.mtime == null ? Date.now() : f.mtime), y = dt.getFullYear() - 1980; + if (y < 0 || y > 119) + err(10); + wbytes(d, b, (y << 25) | ((dt.getMonth() + 1) << 21) | (dt.getDate() << 16) | (dt.getHours() << 11) | (dt.getMinutes() << 5) | (dt.getSeconds() >> 1)), b += 4; + if (c != -1) { + wbytes(d, b, f.crc); + wbytes(d, b + 4, c < 0 ? -c - 2 : c); + wbytes(d, b + 8, f.size); + } + wbytes(d, b + 12, fl); + wbytes(d, b + 14, exl), b += 16; + if (ce != null) { + wbytes(d, b, col); + wbytes(d, b + 6, f.attrs); + wbytes(d, b + 10, ce), b += 14; + } + d.set(fn, b); + b += fl; + if (exl) { + for (var k in ex) { + var exf = ex[k], l = exf.length; + wbytes(d, b, +k); + wbytes(d, b + 2, l); + d.set(exf, b + 4), b += 4 + l; + } + } + if (col) + d.set(co, b), b += col; + return b; +}; +// write zip footer (end of central directory) +var wzf = function (o, b, c, d, e) { + wbytes(o, b, 0x6054B50); // skip disk + wbytes(o, b + 8, c); + wbytes(o, b + 10, c); + wbytes(o, b + 12, d); + wbytes(o, b + 16, e); +}; +/** + * A pass-through stream to keep data uncompressed in a ZIP archive. + */ +var ZipPassThrough = /*#__PURE__*/ (function () { + /** + * Creates a pass-through stream that can be added to ZIP archives + * @param filename The filename to associate with this data stream + */ + function ZipPassThrough(filename) { + this.filename = filename; + this.c = crc(); + this.size = 0; + this.compression = 0; + } + /** + * Processes a chunk and pushes to the output stream. You can override this + * method in a subclass for custom behavior, but by default this passes + * the data through. You must call this.ondata(err, chunk, final) at some + * point in this method. + * @param chunk The chunk to process + * @param final Whether this is the last chunk + */ + ZipPassThrough.prototype.process = function (chunk, final) { + this.ondata(null, chunk, final); + }; + /** + * Pushes a chunk to be added. If you are subclassing this with a custom + * compression algorithm, note that you must push data from the source + * file only, pre-compression. + * @param chunk The chunk to push + * @param final Whether this is the last chunk + */ + ZipPassThrough.prototype.push = function (chunk, final) { + if (!this.ondata) + err(5); + this.c.p(chunk); + this.size += chunk.length; + if (final) + this.crc = this.c.d(); + // we shouldn't really do this cast, but properly handling ArrayBufferLike + // makes the API unergonomic with Buffer + this.process(chunk, final || false); + }; + return ZipPassThrough; +}()); +export { ZipPassThrough }; +// I don't extend because TypeScript extension adds 1kB of runtime bloat +/** + * Streaming DEFLATE compression for ZIP archives. Prefer using AsyncZipDeflate + * for better performance + */ +var ZipDeflate = /*#__PURE__*/ (function () { + /** + * Creates a DEFLATE stream that can be added to ZIP archives + * @param filename The filename to associate with this data stream + * @param opts The compression options + */ + function ZipDeflate(filename, opts) { + var _this = this; + if (!opts) + opts = {}; + ZipPassThrough.call(this, filename); + this.d = new Deflate(opts, function (dat, final) { + _this.ondata(null, dat, final); + }); + this.compression = 8; + this.flag = dbf(opts.level); + } + ZipDeflate.prototype.process = function (chunk, final) { + try { + this.d.push(chunk, final); + } + catch (e) { + this.ondata(e, null, final); + } + }; + /** + * Pushes a chunk to be deflated + * @param chunk The chunk to push + * @param final Whether this is the last chunk + */ + ZipDeflate.prototype.push = function (chunk, final) { + ZipPassThrough.prototype.push.call(this, chunk, final); + }; + return ZipDeflate; +}()); +export { ZipDeflate }; +/** + * Asynchronous streaming DEFLATE compression for ZIP archives + */ +var AsyncZipDeflate = /*#__PURE__*/ (function () { + /** + * Creates an asynchronous DEFLATE stream that can be added to ZIP archives + * @param filename The filename to associate with this data stream + * @param opts The compression options + */ + function AsyncZipDeflate(filename, opts) { + var _this = this; + if (!opts) + opts = {}; + ZipPassThrough.call(this, filename); + this.d = new AsyncDeflate(opts, function (err, dat, final) { + _this.ondata(err, dat, final); + }); + this.compression = 8; + this.flag = dbf(opts.level); + this.terminate = this.d.terminate; + } + AsyncZipDeflate.prototype.process = function (chunk, final) { + this.d.push(chunk, final); + }; + /** + * Pushes a chunk to be deflated + * @param chunk The chunk to push + * @param final Whether this is the last chunk + */ + AsyncZipDeflate.prototype.push = function (chunk, final) { + ZipPassThrough.prototype.push.call(this, chunk, final); + }; + return AsyncZipDeflate; +}()); +export { AsyncZipDeflate }; +// TODO: Better tree shaking +/** + * A zippable archive to which files can incrementally be added + */ +var Zip = /*#__PURE__*/ (function () { + /** + * Creates an empty ZIP archive to which files can be added + * @param cb The callback to call whenever data for the generated ZIP archive + * is available + */ + function Zip(cb) { + this.ondata = cb; + this.u = []; + this.d = 1; + } + /** + * Adds a file to the ZIP archive + * @param file The file stream to add + */ + Zip.prototype.add = function (file) { + var _this = this; + if (!this.ondata) + err(5); + // finishing or finished + if (this.d & 2) + this.ondata(err(4 + (this.d & 1) * 8, 0, 1), null, false); + else { + var f = strToU8(file.filename), fl_1 = f.length; + var com = file.comment, o = com && strToU8(com); + var u = fl_1 != file.filename.length || (o && (com.length != o.length)); + var hl_1 = fl_1 + exfl(file.extra) + 30; + if (fl_1 > 65535) + this.ondata(err(11, 0, 1), null, false); + var header = new u8(hl_1); + wzh(header, 0, file, f, u, -1); + var chks_1 = [header]; + var pAll_1 = function () { + for (var _i = 0, chks_2 = chks_1; _i < chks_2.length; _i++) { + var chk = chks_2[_i]; + _this.ondata(null, chk, false); + } + chks_1 = []; + }; + var tr_1 = this.d; + this.d = 0; + var ind_1 = this.u.length; + var uf_1 = mrg(file, { + f: f, + u: u, + o: o, + t: function () { + if (file.terminate) + file.terminate(); + }, + r: function () { + pAll_1(); + if (tr_1) { + var nxt = _this.u[ind_1 + 1]; + if (nxt) + nxt.r(); + else + _this.d = 1; + } + tr_1 = 1; + } + }); + var cl_1 = 0; + file.ondata = function (err, dat, final) { + if (err) { + _this.ondata(err, dat, final); + _this.terminate(); + } + else { + cl_1 += dat.length; + chks_1.push(dat); + if (final) { + var dd = new u8(16); + wbytes(dd, 0, 0x8074B50); + wbytes(dd, 4, file.crc); + wbytes(dd, 8, cl_1); + wbytes(dd, 12, file.size); + chks_1.push(dd); + uf_1.c = cl_1, uf_1.b = hl_1 + cl_1 + 16, uf_1.crc = file.crc, uf_1.size = file.size; + if (tr_1) + uf_1.r(); + tr_1 = 1; + } + else if (tr_1) + pAll_1(); + } + }; + this.u.push(uf_1); + } + }; + /** + * Ends the process of adding files and prepares to emit the final chunks. + * This *must* be called after adding all desired files for the resulting + * ZIP file to work properly. + */ + Zip.prototype.end = function () { + var _this = this; + if (this.d & 2) { + this.ondata(err(4 + (this.d & 1) * 8, 0, 1), null, true); + return; + } + if (this.d) + this.e(); + else + this.u.push({ + r: function () { + if (!(_this.d & 1)) + return; + _this.u.splice(-1, 1); + _this.e(); + }, + t: function () { } + }); + this.d = 3; + }; + Zip.prototype.e = function () { + var bt = 0, l = 0, tl = 0; + for (var _i = 0, _a = this.u; _i < _a.length; _i++) { + var f = _a[_i]; + tl += 46 + f.f.length + exfl(f.extra) + (f.o ? f.o.length : 0); + } + var out = new u8(tl + 22); + for (var _b = 0, _c = this.u; _b < _c.length; _b++) { + var f = _c[_b]; + wzh(out, bt, f, f.f, f.u, -f.c - 2, l, f.o); + bt += 46 + f.f.length + exfl(f.extra) + (f.o ? f.o.length : 0), l += f.b; + } + wzf(out, bt, this.u.length, tl, l); + this.ondata(null, out, true); + this.d = 2; + }; + /** + * A method to terminate any internal workers used by the stream. Subsequent + * calls to add() will fail. + */ + Zip.prototype.terminate = function () { + for (var _i = 0, _a = this.u; _i < _a.length; _i++) { + var f = _a[_i]; + f.t(); + } + this.d = 2; + }; + return Zip; +}()); +export { Zip }; +export function zip(data, opts, cb) { + if (!cb) + cb = opts, opts = {}; + if (typeof cb != 'function') + err(7); + var r = {}; + fltn(data, '', r, opts); + var k = Object.keys(r); + var lft = k.length, o = 0, tot = 0; + var slft = lft, files = new Array(lft); + var term = []; + var tAll = function () { + for (var i = 0; i < term.length; ++i) + term[i](); + }; + var cbd = function (a, b) { + mt(function () { cb(a, b); }); + }; + mt(function () { cbd = cb; }); + var cbf = function () { + var out = new u8(tot + 22), oe = o, cdl = tot - o; + tot = 0; + for (var i = 0; i < slft; ++i) { + var f = files[i]; + try { + var l = f.c.length; + wzh(out, tot, f, f.f, f.u, l); + var badd = 30 + f.f.length + exfl(f.extra); + var loc = tot + badd; + out.set(f.c, loc); + wzh(out, o, f, f.f, f.u, l, tot, f.m), o += 16 + badd + (f.m ? f.m.length : 0), tot = loc + l; + } + catch (e) { + return cbd(e, null); + } + } + wzf(out, o, files.length, cdl, oe); + cbd(null, out); + }; + if (!lft) + cbf(); + var _loop_1 = function (i) { + var fn = k[i]; + var _a = r[fn], file = _a[0], p = _a[1]; + var c = crc(), size = file.length; + c.p(file); + var f = strToU8(fn), s = f.length; + var com = p.comment, m = com && strToU8(com), ms = m && m.length; + var exl = exfl(p.extra); + var compression = p.level == 0 ? 0 : 8; + var cbl = function (e, d) { + if (e) { + tAll(); + cbd(e, null); + } + else { + var l = d.length; + files[i] = mrg(p, { + size: size, + crc: c.d(), + c: d, + f: f, + m: m, + u: s != fn.length || (m && (com.length != ms)), + compression: compression + }); + o += 30 + s + exl + l; + tot += 76 + 2 * (s + exl) + (ms || 0) + l; + if (!--lft) + cbf(); + } + }; + if (s > 65535) + cbl(err(11, 0, 1), null); + if (!compression) + cbl(null, file); + else if (size < 160000) { + try { + cbl(null, deflateSync(file, p)); + } + catch (e) { + cbl(e, null); + } + } + else + term.push(deflate(file, p, cbl)); + }; + // Cannot use lft because it can decrease + for (var i = 0; i < slft; ++i) { + _loop_1(i); + } + return tAll; +} +/** + * Synchronously creates a ZIP file. Prefer using `zip` for better performance + * with more than one file. + * @param data The directory structure for the ZIP archive + * @param opts The main options, merged with per-file options + * @returns The generated ZIP archive + */ +export function zipSync(data, opts) { + if (!opts) + opts = {}; + var r = {}; + var files = []; + fltn(data, '', r, opts); + var o = 0; + var tot = 0; + for (var fn in r) { + var _a = r[fn], file = _a[0], p = _a[1]; + var compression = p.level == 0 ? 0 : 8; + var f = strToU8(fn), s = f.length; + var com = p.comment, m = com && strToU8(com), ms = m && m.length; + var exl = exfl(p.extra); + if (s > 65535) + err(11); + var d = compression ? deflateSync(file, p) : file, l = d.length; + var c = crc(); + c.p(file); + files.push(mrg(p, { + size: file.length, + crc: c.d(), + c: d, + f: f, + m: m, + u: s != fn.length || (m && (com.length != ms)), + o: o, + compression: compression + })); + o += 30 + s + exl + l; + tot += 76 + 2 * (s + exl) + (ms || 0) + l; + } + var out = new u8(tot + 22), oe = o, cdl = tot - o; + for (var i = 0; i < files.length; ++i) { + var f = files[i]; + wzh(out, f.o, f, f.f, f.u, f.c.length); + var badd = 30 + f.f.length + exfl(f.extra); + out.set(f.c, f.o + badd); + wzh(out, o, f, f.f, f.u, f.c.length, f.o, f.m), o += 16 + badd + (f.m ? f.m.length : 0); + } + wzf(out, o, files.length, cdl, oe); + return out; +} +/** + * Streaming pass-through decompression for ZIP archives + */ +var UnzipPassThrough = /*#__PURE__*/ (function () { + function UnzipPassThrough() { + } + UnzipPassThrough.prototype.push = function (chunk, final) { + // same as ZipPassThrough: cast to retain Buffer ergonomics + this.ondata(null, chunk, final); + }; + UnzipPassThrough.compression = 0; + return UnzipPassThrough; +}()); +export { UnzipPassThrough }; +/** + * Streaming DEFLATE decompression for ZIP archives. Prefer AsyncZipInflate for + * better performance. + */ +var UnzipInflate = /*#__PURE__*/ (function () { + /** + * Creates a DEFLATE decompression that can be used in ZIP archives + */ + function UnzipInflate() { + var _this = this; + this.i = new Inflate(function (dat, final) { + _this.ondata(null, dat, final); + }); + } + UnzipInflate.prototype.push = function (chunk, final) { + try { + this.i.push(chunk, final); + } + catch (e) { + this.ondata(e, null, final); + } + }; + UnzipInflate.compression = 8; + return UnzipInflate; +}()); +export { UnzipInflate }; +/** + * Asynchronous streaming DEFLATE decompression for ZIP archives + */ +var AsyncUnzipInflate = /*#__PURE__*/ (function () { + /** + * Creates a DEFLATE decompression that can be used in ZIP archives + */ + function AsyncUnzipInflate(_, sz) { + var _this = this; + if (sz < 320000) { + this.i = new Inflate(function (dat, final) { + _this.ondata(null, dat, final); + }); + } + else { + this.i = new AsyncInflate(function (err, dat, final) { + _this.ondata(err, dat, final); + }); + this.terminate = this.i.terminate; + } + } + AsyncUnzipInflate.prototype.push = function (chunk, final) { + if (this.i.terminate) + chunk = slc(chunk, 0); + this.i.push(chunk, final); + }; + AsyncUnzipInflate.compression = 8; + return AsyncUnzipInflate; +}()); +export { AsyncUnzipInflate }; +/** + * A ZIP archive decompression stream that emits files as they are discovered + */ +var Unzip = /*#__PURE__*/ (function () { + /** + * Creates a ZIP decompression stream + * @param cb The callback to call whenever a file in the ZIP archive is found + */ + function Unzip(cb) { + this.onfile = cb; + this.k = []; + this.o = { + 0: UnzipPassThrough + }; + this.p = et; + } + /** + * Pushes a chunk to be unzipped + * @param chunk The chunk to push + * @param final Whether this is the last chunk + */ + Unzip.prototype.push = function (chunk, final) { + var _this = this; + if (!this.onfile) + err(5); + if (!this.p) + err(4); + if (this.c > 0) { + var len = Math.min(this.c, chunk.length); + var toAdd = chunk.subarray(0, len); + this.c -= len; + if (this.d) + this.d.push(toAdd, !this.c); + else + this.k[0].push(toAdd); + chunk = chunk.subarray(len); + if (chunk.length) + return this.push(chunk, final); + } + else { + var f = 0, i = 0, is = void 0, buf = void 0; + if (!this.p.length) + buf = chunk; + else if (!chunk.length) + buf = this.p; + else { + buf = new u8(this.p.length + chunk.length); + buf.set(this.p), buf.set(chunk, this.p.length); + } + var l = buf.length, oc = this.c, add = oc && this.d; + var _loop_2 = function () { + var sig = b4(buf, i); + if (sig == 0x4034B50) { + f = 1, is = i; + this_1.d = null; + this_1.c = 0; + var bf = b2(buf, i + 6), cmp_1 = b2(buf, i + 8), u = bf & 2048, dd = bf & 8, fnl = b2(buf, i + 26), es = b2(buf, i + 28); + if (l > i + 30 + fnl + es) { + var chks_3 = []; + this_1.k.unshift(chks_3); + f = 2; + var lsc = b4(buf, i + 18), lsu = b4(buf, i + 22); + var fn_1 = strFromU8(buf.subarray(i + 30, i += 30 + fnl), !u); + var _a = z64hs(buf, i, es, 2, lsc, lsu, 0), sc_1 = _a[0], su_1 = _a[1], z64 = _a[3]; + if (dd) + sc_1 = -1 - z64; + i += es; + this_1.c = sc_1; + var d_1; + var file_1 = { + name: fn_1, + compression: cmp_1, + start: function () { + if (!file_1.ondata) + err(5); + if (!sc_1) + file_1.ondata(null, et, true); + else { + var ctr = _this.o[cmp_1]; + if (!ctr) + file_1.ondata(err(14, 'unknown compression type ' + cmp_1, 1), null, false); + d_1 = sc_1 < 0 ? new ctr(fn_1) : new ctr(fn_1, sc_1, su_1); + d_1.ondata = function (err, dat, final) { file_1.ondata(err, dat, final); }; + for (var _i = 0, chks_4 = chks_3; _i < chks_4.length; _i++) { + var dat = chks_4[_i]; + d_1.push(dat, false); + } + if (_this.k[0] == chks_3 && _this.c) + _this.d = d_1; + else + d_1.push(et, true); + } + }, + terminate: function () { + if (d_1 && d_1.terminate) + d_1.terminate(); + } + }; + if (sc_1 >= 0) + file_1.size = sc_1, file_1.originalSize = su_1; + this_1.onfile(file_1); + } + return "break"; + } + else if (oc) { + if (sig == 0x8074B50) { + is = i += 12 + (oc == -2 && 8), f = 3, this_1.c = 0; + return "break"; + } + else if (sig == 0x2014B50) { + is = i -= 4, f = 3, this_1.c = 0; + return "break"; + } + } + }; + var this_1 = this; + for (; i < l - 4; ++i) { + var state_1 = _loop_2(); + if (state_1 === "break") + break; + } + this.p = et; + if (oc < 0) { + var dat = f ? buf.subarray(0, is - 12 - (oc == -2 && 8) - (b4(buf, is - 16) == 0x8074B50 && 4)) : buf.subarray(0, i); + if (add) + add.push(dat, !!f); + else + this.k[+(f == 2)].push(dat); + } + if (f & 2) + return this.push(buf.subarray(i), final); + this.p = buf.subarray(i); + } + if (final) { + if (this.c) + err(13); + this.p = null; + } + }; + /** + * Registers a decoder with the stream, allowing for files compressed with + * the compression type provided to be expanded correctly + * @param decoder The decoder constructor + */ + Unzip.prototype.register = function (decoder) { + this.o[decoder.compression] = decoder; + }; + return Unzip; +}()); +export { Unzip }; +var mt = typeof queueMicrotask == 'function' ? queueMicrotask : typeof setTimeout == 'function' ? setTimeout : function (fn) { fn(); }; +export function unzip(data, opts, cb) { + if (!cb) + cb = opts, opts = {}; + if (typeof cb != 'function') + err(7); + var term = []; + var tAll = function () { + for (var i = 0; i < term.length; ++i) + term[i](); + }; + var files = {}; + var cbd = function (a, b) { + mt(function () { cb(a, b); }); + }; + mt(function () { cbd = cb; }); + var e = data.length - 22; + for (; b4(data, e) != 0x6054B50; --e) { + if (!e || data.length - e > 65558) { + cbd(err(13, 0, 1), null); + return tAll; + } + } + ; + var lft = b2(data, e + 8); + if (lft) { + var c = lft; + var o = b4(data, e + 16); + var z = b4(data, e - 20) == 0x7064B50; + if (z) { + var ze = b4(data, e - 12); + z = b4(data, ze) == 0x6064B50; + if (z) { + c = lft = b4(data, ze + 32); + o = b4(data, ze + 48); + } + } + var fltr = opts && opts.filter; + var _loop_3 = function (i) { + var _a = zh(data, o, z), c_1 = _a[0], sc = _a[1], su = _a[2], fn = _a[3], no = _a[4], off = _a[5], b = slzh(data, off); + o = no; + var cbl = function (e, d) { + if (e) { + tAll(); + cbd(e, null); + } + else { + if (d) + files[fn] = d; + if (!--lft) + cbd(null, files); + } + }; + if (!fltr || fltr({ + name: fn, + size: sc, + originalSize: su, + compression: c_1 + })) { + if (!c_1) + cbl(null, slc(data, b, b + sc)); + else if (c_1 == 8) { + var infl = data.subarray(b, b + sc); + // Synchronously decompress under 512KB, or barely-compressed data + if (su < 524288 || sc > 0.8 * su) { + try { + cbl(null, inflateSync(infl, { out: new u8(su) })); + } + catch (e) { + cbl(e, null); + } + } + else + term.push(inflate(infl, { size: su }, cbl)); + } + else + cbl(err(14, 'unknown compression type ' + c_1, 1), null); + } + else + cbl(null, null); + }; + for (var i = 0; i < c; ++i) { + _loop_3(i); + } + } + else + cbd(null, {}); + return tAll; +} +/** + * Synchronously decompresses a ZIP archive. Prefer using `unzip` for better + * performance with more than one file. + * @param data The raw compressed ZIP file + * @param opts The ZIP extraction options + * @returns The decompressed files + */ +export function unzipSync(data, opts) { + var files = {}; + var e = data.length - 22; + for (; b4(data, e) != 0x6054B50; --e) { + if (!e || data.length - e > 65558) + err(13); + } + ; + var c = b2(data, e + 8); + if (!c) + return {}; + var o = b4(data, e + 16); + var z = b4(data, e - 20) == 0x7064B50; + if (z) { + var ze = b4(data, e - 12); + z = b4(data, ze) == 0x6064B50; + if (z) { + c = b4(data, ze + 32); + o = b4(data, ze + 48); + } + } + var fltr = opts && opts.filter; + for (var i = 0; i < c; ++i) { + var _a = zh(data, o, z), c_2 = _a[0], sc = _a[1], su = _a[2], fn = _a[3], no = _a[4], off = _a[5], b = slzh(data, off); + o = no; + if (!fltr || fltr({ + name: fn, + size: sc, + originalSize: su, + compression: c_2 + })) { + if (!c_2) + files[fn] = slc(data, b, b + sc); + else if (c_2 == 8) + files[fn] = inflateSync(data.subarray(b, b + sc), { out: new u8(su) }); + else + err(14, 'unknown compression type ' + c_2); + } + } + return files; +} diff --git a/docs/ai-literacy/index.html b/docs/ai-literacy/index.html index aac255c..1956e1d 100644 --- a/docs/ai-literacy/index.html +++ b/docs/ai-literacy/index.html @@ -2,4 +2,4 @@ Understand AI. Question the Output. Build a Skill. — Loptr Lab Training -
LOPTR LAB / AI LITERACY

Understand AI. Question the Output. Build a Skill.

AI Literacy: Learn to Question, Test, and Evaluate AI

AI is changing how people research, create, communicate, and work. Using it effectively involves more than writing prompts: you need to check its results.

This free introductory class teaches one practical skill: writing clear criteria to evaluate AI-generated responses. You do not need to become an AI engineer to understand what AI does or recognize its limitations.

No programming experience is required. Explore without an account or job application. Work at your own pace on a phone, tablet or computer; a paid subscription is unnecessary.

Start the eight-part class

What you will learn

What does a model do?

A model is software trained to find patterns in examples. A text-generating model uses patterns and the context you supply to produce a response, one piece of text at a time. It can draft, summarize and transform information. A fluent response can still contain invented details or mistakes.

Prompt
The instruction or question you give the model.
Input file
Material supplied with a task, such as a fictional price list.
Model response
The generated answer or deliverable you inspect.
Rubric
A set of criteria: clear rules for deciding whether the response meets requirements.
Evaluation
Checking output against those rules and recording evidence.
Hallucination
A generated claim that sounds plausible but is unsupported or false.

Start by checking names, numbers and citations against the task facts. OpenAI’s evaluation guidance explains why repeatable tests and human judgment matter.

What people hear about AI — and what the evidence actually shows

“AI knows everything.”

Models have limits in training, context and access to current sources. A tool connection can help retrieve information, but still needs checking.

Try it: Ask for a fictional vendor’s opening hours: the answer must use the supplied schedule, not invent one. Stanford AI Index: uneven capabilities.

“AI always tells the truth.”

A model can produce unsupported statements. It does not guarantee that each sentence was fact-checked.

Try it: A convincing citation may not exist; open it and check the claim. NIST AI risk management resources.

“You need to know how to code to work with AI.”

This class uses careful reading and writing. Certain evaluation or quality-review tasks use research or domain knowledge; employer and project requirements vary.

Try it: You can compare a fictional itinerary with closure dates without writing software. Educational evaluation examples.

“Everyone working in AI is an engineer.”

AI-assisted work includes writing, research, education and quality review alongside specialized technical careers. Some roles require advanced credentials.

Try it: A teacher can review whether a generated lesson meets a learning objective. Teacher skills and rubrics.

“AI is replacing every job.”

Tasks and occupations differ. BLS projects growth in some occupations and declines in others; a projection is not a guarantee.

Try it: Software developers and customer service representatives have different projected trends. BLS occupational projections.

“AI will automatically create new opportunities for everyone.”

Access, training and job changes are uneven. Stanford reports early-career employment declines in some AI-exposed work, with limitations in the data.

Try it: A growing occupation can still be difficult for a new entrant to access. Stanford employment evidence.

“You need expensive equipment or a paid subscription to learn AI.”

You can complete this lesson with the fictional response on the page, without calling any AI service. Other courses or tools may have different requirements.

Try it: Grade the supplied response on your current device, or write your criteria on paper. Free practice in this class.

“An AI-generated answer is correct because it sounds convincing.”

Writing style is not evidence of accuracy. Use specific tests and inspect supporting facts.

Try it: A polished itinerary still fails if it schedules a visit on a closure day. Evaluation best practices.

AI and Work: What the Data Tells Us

United States · projected employment change, 2024–2034. These percentages compare expected employment in 2034 with employment in 2024. They are not counts of current AI vacancies, and these occupations include work beyond AI.

BLS projections; last verified
Occupation or groupProjected growth
Computer and mathematical occupations10.1%
All occupations3.1%
Data scientists33.5%
Software developers15.8%

Sources: BLS overview (occupational group) and BLS selected occupations (other rows).

Static reviewed snapshot, verified 7 November 2026. Check source dates before relying on it.

Population: U.S. wage and salary employment across industries plus self-employment. BLS published the overview in January 2026 and the selected-occupations article on 16 July 2026. The snapshot is periodically reviewed, not live or real-time.

BLS also projects declines for some administrative and customer-service occupations. Its long-term projections do not predict an individual’s job outcome.

Contrasting evidence: Stanford’s 21 October 2026 report describes falling employment among young workers in highly AI-exposed software-development and customer-service work. The underlying payroll data do not represent every worker, and association with AI exposure alone does not prove AI caused a change. Monthly observations and decade-long projections answer different questions.

Different kinds of change: automation hands a task to software; augmentation helps a person do it. Existing occupations may acquire checking responsibilities. Specialized AI careers can require advanced study. Entry-level evaluation tasks may emphasize careful reading, writing or subject knowledge; availability and requirements vary.

For further context, explore the Stanford AI Index and Lightcast’s job-posting analysis. Postings measure advertised demand, not completed hires or guaranteed openings. These sources do not endorse this class.

Download the versioned data and source metadata. If a source becomes unavailable, this dated snapshot remains visible; missing values are never estimated.

A small evaluation reference library

Links and descriptions checked 7 November 2026. Start with this class before the technical references. External organizations are reference providers, not partners.

Where you could apply this skill

Optional examples: check a PIXIE handoff for stated next actions; evaluate a synthetic educational exercise for 50 Ways to Leave Another; verify a Made-Sick resource description against its cited source; or check Duet and game documentation against specified rules. These are contexts for practice, not new integrations or assignments.

Use the existing portfolio-evidence workflow to explain your own contribution. GitHub remains the shared record; no second tracker is needed.

Interested in Exploring AI-Related Work?

Some organizations offer paid projects involving AI evaluation, research, writing, and specialist knowledge. Opportunities vary by location, experience, availability, and project requirements.

Handshake is one place to explore potential opportunities. Applying is optional, and completing this class does not guarantee acceptance or paid work.

Explore Handshake (referral link)

Referral disclosure: This is a referral link. Loptr Lab's founder may receive compensation if an eligible referral completes qualifying paid work. You are not required to use this link or apply to participate in the training.

Before applying, check Handshake’s current referral conditions and the requirements of the specific project. Referral rewards are separate from learner compensation. Loptr Lab does not represent itself as an authorized Handshake training provider or recruiting partner.

Independent, non-referral route: research occupations through the BLS Occupational Outlook Handbook and check requirements directly with employers.

\ No newline at end of file +
LOPTR LAB / AI LITERACY

Understand AI. Question the Output. Build a Skill.

AI Literacy: Learn to Question, Test, and Evaluate AI

AI is changing how people research, create, communicate, and work. Using it effectively involves more than writing prompts: you need to check its results.

This free introductory class teaches one practical skill: writing clear criteria to evaluate AI-generated responses. You do not need to become an AI engineer to understand what AI does or recognize its limitations.

No programming experience is required. Explore without an account or job application. Work at your own pace on a phone, tablet or computer; a paid subscription is unnecessary.

Start the eight-part class

What you will learn

What does a model do?

A model is software trained to find patterns in examples. A text-generating model uses patterns and the context you supply to produce a response, one piece of text at a time. It can draft, summarize and transform information. A fluent response can still contain invented details or mistakes.

Prompt
The instruction or question you give the model.
Input file
Material supplied with a task, such as a fictional price list.
Model response
The generated answer or deliverable you inspect.
Rubric
A set of criteria: clear rules for deciding whether the response meets requirements.
Evaluation
Checking output against those rules and recording evidence.
Hallucination
A generated claim that sounds plausible but is unsupported or false.

Start by checking names, numbers and citations against the task facts. OpenAI’s evaluation guidance explains why repeatable tests and human judgment matter.

What people hear about AI — and what the evidence actually shows

“AI knows everything.”

Models have limits in training, context and access to current sources. A tool connection can help retrieve information, but still needs checking.

Try it: Ask for a fictional vendor’s opening hours: the answer must use the supplied schedule, not invent one. Stanford AI Index: uneven capabilities.

“AI always tells the truth.”

A model can produce unsupported statements. It does not guarantee that each sentence was fact-checked.

Try it: A convincing citation may not exist; open it and check the claim. NIST AI risk management resources.

“You need to know how to code to work with AI.”

This class uses careful reading and writing. Certain evaluation or quality-review tasks use research or domain knowledge; employer and project requirements vary.

Try it: You can compare a fictional itinerary with closure dates without writing software. Educational evaluation examples.

“Everyone working in AI is an engineer.”

AI-assisted work includes writing, research, education and quality review alongside specialized technical careers. Some roles require advanced credentials.

Try it: A teacher can review whether a generated lesson meets a learning objective. Teacher skills and rubrics.

“AI is replacing every job.”

Tasks and occupations differ. BLS projects growth in some occupations and declines in others; a projection is not a guarantee.

Try it: Software developers and customer service representatives have different projected trends. BLS occupational projections.

“AI will automatically create new opportunities for everyone.”

Access, training and job changes are uneven. Stanford reports early-career employment declines in some AI-exposed work, with limitations in the data.

Try it: A growing occupation can still be difficult for a new entrant to access. Stanford employment evidence.

“You need expensive equipment or a paid subscription to learn AI.”

You can complete this lesson with the fictional response on the page, without calling any AI service. Other courses or tools may have different requirements.

Try it: Grade the supplied response on your current device, or write your criteria on paper. Free practice in this class.

“An AI-generated answer is correct because it sounds convincing.”

Writing style is not evidence of accuracy. Use specific tests and inspect supporting facts.

Try it: A polished itinerary still fails if it schedules a visit on a closure day. Evaluation best practices.

AI and Work: What the Data Tells Us

United States · projected employment change, 2024–2034. These percentages compare expected employment in 2034 with employment in 2024. They are not counts of current AI vacancies, and these occupations include work beyond AI.

BLS projections; last verified
Occupation or groupProjected growth
Computer and mathematical occupations10.1%
All occupations3.1%
Data scientists33.5%
Software developers15.8%

Sources: BLS overview (occupational group) and BLS selected occupations (other rows).

Static reviewed snapshot, verified 7 November 2026. Check source dates before relying on it.

Population: U.S. wage and salary employment across industries plus self-employment. BLS published the overview in January 2026 and the selected-occupations article on 16 July 2026. The snapshot is periodically reviewed, not live or real-time.

BLS also projects declines for some administrative and customer-service occupations. Its long-term projections do not predict an individual’s job outcome.

Contrasting evidence: Stanford’s 21 October 2026 report describes falling employment among young workers in highly AI-exposed software-development and customer-service work. The underlying payroll data do not represent every worker, and association with AI exposure alone does not prove AI caused a change. Monthly observations and decade-long projections answer different questions.

Different kinds of change: automation hands a task to software; augmentation helps a person do it. Existing occupations may acquire checking responsibilities. Specialized AI careers can require advanced study. Entry-level evaluation tasks may emphasize careful reading, writing or subject knowledge; availability and requirements vary.

For further context, explore the Stanford AI Index and Lightcast’s job-posting analysis. Postings measure advertised demand, not completed hires or guaranteed openings. These sources do not endorse this class.

Download the versioned data and source metadata. If a source becomes unavailable, this dated snapshot remains visible; missing values are never estimated.

A small evaluation reference library

Links and descriptions checked 7 November 2026. Start with this class before the technical references. External organizations are reference providers, not partners.

Where you could apply this skill

Optional examples: check a PIXIE handoff for stated next actions; evaluate a synthetic educational exercise for 50 Ways to Leave Another; verify a Made-Sick resource description against its cited source; or check Duet and game documentation against specified rules. These are contexts for practice, not new integrations or assignments.

Use the existing portfolio-evidence workflow to explain your own contribution. GitHub remains the shared record; no second tracker is needed.

Interested in Exploring AI-Related Work?

Some organizations offer paid projects involving AI evaluation, research, writing, and specialist knowledge. Opportunities vary by location, experience, availability, and project requirements.

Handshake is one place to explore potential opportunities. Applying is optional, and completing this class does not guarantee acceptance or paid work.

Explore Handshake (referral link)

Referral disclosure: This is a referral link. Loptr Lab's founder may receive compensation if an eligible referral completes qualifying paid work. You are not required to use this link or apply to participate in the training.

Before applying, check Handshake’s current referral conditions and the requirements of the specific project. Referral rewards are separate from learner compensation. Loptr Lab does not represent itself as an authorized Handshake training provider or recruiting partner.

Independent, non-referral route: research occupations through the BLS Occupational Outlook Handbook and check requirements directly with employers.

\ No newline at end of file diff --git a/docs/ai-literacy/library.html b/docs/ai-literacy/library.html new file mode 100644 index 0000000..0a79e80 --- /dev/null +++ b/docs/ai-literacy/library.html @@ -0,0 +1,114 @@ + + + + + + Library — Loptr Lab Training + + + + + + +
+
LOPTR LAB / CREATIVE FILING
+

Library

+

Your practice work, on this device.

+ +
+
+
+

Find

+ +
+ +
+

+ Loading local Library… +

+
+
+

Recent

+

+
+
+
+

Labels

+

+ Words organize your work. “Verified” is your label, not professional + approval. +

+
+
+
+ +
+
+
+

Keep a backup you can check

+

+ Browser storage can be cleared or evicted. Private Browsing and Home + Screen storage may behave differently. This Library belongs only to + ; another preview or device has a + separate Library. +

+

+ After export, check Safari Downloads or Files for the complete ZIP. + Choosing iCloud Drive is your provider action; PIXIE does not sync it. + Without a downloaded backup, cleared or uncommitted work may be + unrecoverable. +

+

+ Imports stay local. Device dates are not proof of authorship. Imported + practice is learner-produced, not independent certification. Use + PIXIE’s existing checkpoint workflow only when you deliberately choose + to share. +

+

+ +
+ +
+ +

Review import

+
+

+
+ +
+
+ + + diff --git a/docs/ai-literacy/rubrics.html b/docs/ai-literacy/rubrics.html index 1551dd1..30e83c9 100644 --- a/docs/ai-literacy/rubrics.html +++ b/docs/ai-literacy/rubrics.html @@ -1,5 +1,5 @@ Write a rubric. Test the response. — Loptr Lab Training - -
LOPTR LAB / AI LITERACY

Write a rubric. Test the response.

Eight short steps, at your own pace

Each step gives an explanation and example, then asks one question. Use the fictional task below throughout. With JavaScript enabled, answer checking offers one retry and then an explanation. Nothing is sent to a model or server.

Without JavaScript, use the worked examples and “Answer and takeaway” sections. Open-ended writing uses a self-review checklist; automatic hints cannot certify the quality of a rubric. Write on paper if you prefer.

Your fictional task and input file

Everything below is fictional. Luma Quay, all vendors, identifiers, rates and rules are invented for this lesson.

Synthetic task prompt

Create an itinerary for two travelers, Ari and Bo, for Monday 8 June through Wednesday 10 June in fictional year F1. Include one attraction and one lunch each day, use each attraction once, and use only the supplied vendors. Both travelers require nut-free meals. Show each traveler’s total and the party total, including the shared shuttle. Budget: at most 100 credits for the party.

Synthetic input facts

Complete fictional deliverable A

Attractions: 25 per person, 50 party. Lunches: 15 per person, 30 party. Shuttle: 10 per person, 20 party. Total: 50 per person, 100 party.

1. Warm-up: the four parts

A prompt asks for an itinerary; the input file supplies prices; the response is the itinerary; the rubric checks it. Example: “Party total is exactly 100 credits” is a rubric criterion.

What is the supplied vendor price list?
Answer and takeaway

The price list supplies facts to the task; it is the input file.

Takeaway: Keep instructions, evidence, response and grading rules distinct.

2. Four principles

Atomic means one check. Verifiable means PASS/FAIL from the deliverable alone. Objective means a rule rather than personal taste. Self-contained means all grading facts are in the criterion. Example: “States the full-trip party total as exactly 100 credits, including the shared 20-credit shuttle.”

Which criterion meets all four principles?
Answer and takeaway

The exact total supplies a single observable rule and its expected value. “Delightful” is subjective; “match the file” requires an external lookup.

Takeaway: A reviewer should not have to guess what PASS means.

3. Practice A: diagnose a flawed criterion

Flawed: “Lists a nut-free lunch and states the correct party total.” Two things can succeed or fail independently. Example correction for the first check: “Lists Moss Kitchen for Monday lunch; Moss Kitchen is nut-free.”

Which principle does combining lunch and total break?
Answer and takeaway

Atomic fails: lunch choice and arithmetic are separate checks. Write a second criterion with the exact 100-credit party total.

Takeaway: Split checks that can have different outcomes.

4. Practice B: rewrite

Flawed: “The cost looks reasonable.” It uses an opinion and hides the expected value. Worked rewrite: “States Ari’s full-trip total as exactly 50 credits, including 10 credits for the shuttle.”

Which rewrite makes the party-total check objective and self-contained?
Answer and takeaway

Use the exact expected 100-credit party total and include the shuttle fact. No taste judgment or source lookup is needed.

Takeaway: Replace vague praise with a specific observable value.

5. Practice C: assign and justify weights

Positive weights reward fulfilled requirements. Negative weights penalize a defined mistake when that mistake is present. A PASS on a negative criterion triggers the penalty. Example: “Schedules any Ember Café lunch, whose meals contain nuts” has weight −6; a consistent currency label earns +1. Dietary compliance has higher stakes here. Explicit requirements come directly from the prompt or input; implicit requirements are necessary inferences, such as consistent units.

Which weighting and reason fits this task?
Answer and takeaway

Penalize the defined nut-containing meal mistake; reward consistent units modestly. Weights express task stakes, not equal-sized boxes.

Takeaway: Define the negative trigger before assigning its penalty.

6. Practice D: split without changing total weight

Flawed +6 criterion: “Lists Lantern Hall on Monday and Glass Garden on Tuesday.” Split into two self-contained checks: Lantern Hall on Monday, when open (+3); Glass Garden on Tuesday, when open (+3). The sum remains +6. Splitting allows partial credit when only one part passes.

Which split preserves the original +6 total?
Answer and takeaway

3 + 3 = 6. The other splits double or increase the weight. Preserve the total, while recognizing partial credit changes.

Takeaway: Split the requirement and preserve the weight budget.

7. Practice E: exclude an unverifiable criterion

Flawed: “The assistant checked every vendor’s live website before answering.” The itinerary does not prove an unseen browsing action. Replace process guesses with output checks: “Does not schedule Cloud Walk on Tuesday, when it is unavailable.”

What should happen to the unseen-browsing criterion?
Answer and takeaway

Exclude it from this output rubric. A process audit would need separately authorized evidence; do not infer hidden activity from polished writing.

Takeaway: Grade observable output; do not invent evidence of hidden work.

How weighted PASS/FAIL scoring works

Teaching rubric: eight checks, 18 possible positive points
Single checkWeightOriginDeliverable A evidence
Lists Lantern Hall on Monday 8 June, when it is open.+3ExplicitPASS: Monday names Lantern Hall.
Lists Glass Garden on Tuesday 9 June, when it is open.+3ExplicitPASS: Tuesday names Glass Garden.
Lists Cloud Walk on Wednesday 10 June, outside its Tuesday blackout.+3ExplicitPASS: Wednesday names Cloud Walk.
States Ari’s full-trip total as exactly 50 credits, including 10 credits for the shuttle.+2ExplicitPASS: Ari’s total is 50.
States Bo’s full-trip total as exactly 50 credits, including 10 credits for the shuttle.+2ExplicitPASS: Bo’s total is 50.
States the full-trip party total as exactly 100 credits, including the shared 20-credit shuttle.+4ExplicitPASS: party total is 100.
Uses the same currency label, credits, for every stated monetary total.+1ImplicitPASS: every monetary total uses credits.
Schedules at least one lunch at Ember Café, whose meals contain nuts.−6ExplicitFAIL: no Ember Café lunch is scheduled; no penalty.

For each criterion, PASS means its statement is true. Add its weight on PASS; add zero on FAIL. Deliverable A earns 3 + 3 + 3 + 2 + 2 + 4 + 1 + 0 = 18 points. The negative criterion is a defined mistake: it fails here, so the penalty does not apply. Raw scores can be negative; this exercise has a −6 minimum and +18 maximum, not a certification threshold.

Fictional deliverable B: spot the failures

Keep A’s attractions and printed costs unchanged, but change Tuesday lunch to Ember Café. The printed Ari total is still 50, Bo 50 and party 100 credits. This is deliberately flawed: the meal violates the restriction and the printed costs no longer match its choices.

Under these eight checks, all seven positive statements remain true and the negative statement now passes: 18 − 6 = 12 points. That does not make B acceptable. This teaching rubric does not check every requirement or recalculate every possible itinerary. Add separate arithmetic-consistency and daily nut-free-lunch checks for a full evaluation. Critical requirements can also be hard gates, independent of score.

Advanced example: the existing Veiled Dominion reviewer scorecard uses weighted 1–5 dimensions and hard gates. Some broad dimensions combine judgments; they differ from the strict atomic PASS/FAIL rules taught here.

B earns 18 positive points and triggers the −6 mistake. What is the raw score?

Score answer

12 points. Subtract 6 once; do not turn a penalty into a reward.

8. Wrap-up: write three criteria

Now write your own three single-check criteria for deliverable A. Each needs a signed weight and an explicit/implicit label. Example: “States Bo’s full-trip total as exactly 50 credits, including 10 credits for the shuttle” (+2, explicit). Choose three different checks and explain their stakes.

Below, write one criterion at a time. Hints flag possible issues with wording; they do not understand meaning or decide PASS/FAIL. After one revision, use the self-review checklist and examples to improve your draft.

Criterion 1 of 3

Write one criterion for the fictional itinerary

Targeted self-review

Independent exercise: fictional deliverable C swaps A’s Monday and Tuesday attractions: Monday Glass Garden; Tuesday Lantern Hall; Wednesday Cloud Walk. All lunches are Moss Kitchen and totals remain 50 per traveler, 100 party. Score your three criteria against C. Record PASS/FAIL and the exact evidence, then rewrite one weak criterion. For a negative criterion, PASS means its mistake occurred.

Independent exercise feedback

Glass Garden is closed Monday. Under the eight-check teaching rubric, the Monday Lantern Hall and Tuesday Glass Garden checks both fail; the other positive checks pass and the meal penalty does not trigger. Score: 18 − 3 − 3 = 12. Your three-criterion score depends on the checks you wrote; explain each outcome rather than borrowing this total.

Takeaway: a useful rubric makes a specific failure visible and supports a concrete revision.

Finish with a fresh scenario and scoring record

The worked examples above stay available for learning. This final exercise varies the fictional vendors, prices and mistakes each time you open the page. Calculate the totals, then score three checks one at a time. The generated arithmetic and answer key follow fixed rules. You have two assessment attempts, each with four checks; the practice target is 4/4. After the first unsuccessful attempt, study feedback tied to your scenario before trying a changed response with the same facts. After a second unsuccessful attempt, explore further study and a path based on the checks you answered well. These are learning suggestions, not job-suitability decisions.

Use the record with PIXIE in the classroom pilot

Ask PIXIE: “Help me review my synthetic scenario, three criteria, first-attempt answers, retries, final scoring and one improvement. Identify the next useful practice step. Keep it as a draft for my existing Training checkpoint.” The download includes the scenario facts and scoring history so a new session can use evidence rather than memory.

Review visibility and attribution before sharing through your existing GitHub issue. The record is learner-reported practice; PIXIE does not automatically receive, save or verify classroom progress. Keep private learner and academic records out of public checkpoints. No new classroom tracker is created.

Keep a shareable practice artifact

Use the synthetic practice-artifact template. Include your prompt, input facts, three criteria, weights, classifications, fictional response, PASS/FAIL evidence and one improvement made after feedback.

Your entries stay in this page’s memory and are erased on reload or reset. Download only if you choose; do not enter personal, employment, medical, academic or financial records.

Sharing is optional. Follow the existing contribution and portfolio workflow. Review your artifact and attribution before submitting through your own GitHub account: issues and comments are public. Keep your existing checkpoint URL and add progress there; an exercise is not maintainer acceptance, certification or membership.

\ No newline at end of file + +
LOPTR LAB / AI LITERACY

Write a rubric. Test the response.

Start working to save an exercise on this device.

Eight short steps, at your own pace

Each step gives an explanation and example, then asks one question. Use the fictional task below throughout. With JavaScript enabled, answer checking offers one retry and then an explanation. Work you create is saved in this browser’s local Library. Nothing is sent to a model or server.

Without JavaScript, use the worked examples and “Answer and takeaway” sections. Open-ended writing uses a self-review checklist; automatic hints cannot certify the quality of a rubric. Write on paper if you prefer.

Your fictional task and input file

Everything below is fictional. Luma Quay, all vendors, identifiers, rates and rules are invented for this lesson.

Synthetic task prompt

Create an itinerary for two travelers, Ari and Bo, for Monday 8 June through Wednesday 10 June in fictional year F1. Include one attraction and one lunch each day, use each attraction once, and use only the supplied vendors. Both travelers require nut-free meals. Show each traveler’s total and the party total, including the shared shuttle. Budget: at most 100 credits for the party.

Synthetic input facts

Complete fictional deliverable A

Attractions: 25 per person, 50 party. Lunches: 15 per person, 30 party. Shuttle: 10 per person, 20 party. Total: 50 per person, 100 party.

1. Warm-up: the four parts

A prompt asks for an itinerary; the input file supplies prices; the response is the itinerary; the rubric checks it. Example: “Party total is exactly 100 credits” is a rubric criterion.

What is the supplied vendor price list?
Answer and takeaway

The price list supplies facts to the task; it is the input file.

Takeaway: Keep instructions, evidence, response and grading rules distinct.

2. Four principles

Atomic means one check. Verifiable means PASS/FAIL from the deliverable alone. Objective means a rule rather than personal taste. Self-contained means all grading facts are in the criterion. Example: “States the full-trip party total as exactly 100 credits, including the shared 20-credit shuttle.”

Which criterion meets all four principles?
Answer and takeaway

The exact total supplies a single observable rule and its expected value. “Delightful” is subjective; “match the file” requires an external lookup.

Takeaway: A reviewer should not have to guess what PASS means.

3. Practice A: diagnose a flawed criterion

Flawed: “Lists a nut-free lunch and states the correct party total.” Two things can succeed or fail independently. Example correction for the first check: “Lists Moss Kitchen for Monday lunch; Moss Kitchen is nut-free.”

Which principle does combining lunch and total break?
Answer and takeaway

Atomic fails: lunch choice and arithmetic are separate checks. Write a second criterion with the exact 100-credit party total.

Takeaway: Split checks that can have different outcomes.

4. Practice B: rewrite

Flawed: “The cost looks reasonable.” It uses an opinion and hides the expected value. Worked rewrite: “States Ari’s full-trip total as exactly 50 credits, including 10 credits for the shuttle.”

Which rewrite makes the party-total check objective and self-contained?
Answer and takeaway

Use the exact expected 100-credit party total and include the shuttle fact. No taste judgment or source lookup is needed.

Takeaway: Replace vague praise with a specific observable value.

5. Practice C: assign and justify weights

Positive weights reward fulfilled requirements. Negative weights penalize a defined mistake when that mistake is present. A PASS on a negative criterion triggers the penalty. Example: “Schedules any Ember Café lunch, whose meals contain nuts” has weight −6; a consistent currency label earns +1. Dietary compliance has higher stakes here. Explicit requirements come directly from the prompt or input; implicit requirements are necessary inferences, such as consistent units.

Which weighting and reason fits this task?
Answer and takeaway

Penalize the defined nut-containing meal mistake; reward consistent units modestly. Weights express task stakes, not equal-sized boxes.

Takeaway: Define the negative trigger before assigning its penalty.

6. Practice D: split without changing total weight

Flawed +6 criterion: “Lists Lantern Hall on Monday and Glass Garden on Tuesday.” Split into two self-contained checks: Lantern Hall on Monday, when open (+3); Glass Garden on Tuesday, when open (+3). The sum remains +6. Splitting allows partial credit when only one part passes.

Which split preserves the original +6 total?
Answer and takeaway

3 + 3 = 6. The other splits double or increase the weight. Preserve the total, while recognizing partial credit changes.

Takeaway: Split the requirement and preserve the weight budget.

7. Practice E: exclude an unverifiable criterion

Flawed: “The assistant checked every vendor’s live website before answering.” The itinerary does not prove an unseen browsing action. Replace process guesses with output checks: “Does not schedule Cloud Walk on Tuesday, when it is unavailable.”

What should happen to the unseen-browsing criterion?
Answer and takeaway

Exclude it from this output rubric. A process audit would need separately authorized evidence; do not infer hidden activity from polished writing.

Takeaway: Grade observable output; do not invent evidence of hidden work.

How weighted PASS/FAIL scoring works

Teaching rubric: eight checks, 18 possible positive points
Single checkWeightOriginDeliverable A evidence
Lists Lantern Hall on Monday 8 June, when it is open.+3ExplicitPASS: Monday names Lantern Hall.
Lists Glass Garden on Tuesday 9 June, when it is open.+3ExplicitPASS: Tuesday names Glass Garden.
Lists Cloud Walk on Wednesday 10 June, outside its Tuesday blackout.+3ExplicitPASS: Wednesday names Cloud Walk.
States Ari’s full-trip total as exactly 50 credits, including 10 credits for the shuttle.+2ExplicitPASS: Ari’s total is 50.
States Bo’s full-trip total as exactly 50 credits, including 10 credits for the shuttle.+2ExplicitPASS: Bo’s total is 50.
States the full-trip party total as exactly 100 credits, including the shared 20-credit shuttle.+4ExplicitPASS: party total is 100.
Uses the same currency label, credits, for every stated monetary total.+1ImplicitPASS: every monetary total uses credits.
Schedules at least one lunch at Ember Café, whose meals contain nuts.−6ExplicitFAIL: no Ember Café lunch is scheduled; no penalty.

For each criterion, PASS means its statement is true. Add its weight on PASS; add zero on FAIL. Deliverable A earns 3 + 3 + 3 + 2 + 2 + 4 + 1 + 0 = 18 points. The negative criterion is a defined mistake: it fails here, so the penalty does not apply. Raw scores can be negative; this exercise has a −6 minimum and +18 maximum, not a certification threshold.

Fictional deliverable B: spot the failures

Keep A’s attractions and printed costs unchanged, but change Tuesday lunch to Ember Café. The printed Ari total is still 50, Bo 50 and party 100 credits. This is deliberately flawed: the meal violates the restriction and the printed costs no longer match its choices.

Under these eight checks, all seven positive statements remain true and the negative statement now passes: 18 − 6 = 12 points. That does not make B acceptable. This teaching rubric does not check every requirement or recalculate every possible itinerary. Add separate arithmetic-consistency and daily nut-free-lunch checks for a full evaluation. Critical requirements can also be hard gates, independent of score.

Advanced example: the existing Veiled Dominion reviewer scorecard uses weighted 1–5 dimensions and hard gates. Some broad dimensions combine judgments; they differ from the strict atomic PASS/FAIL rules taught here.

B earns 18 positive points and triggers the −6 mistake. What is the raw score?

Score answer

12 points. Subtract 6 once; do not turn a penalty into a reward.

8. Wrap-up: write three criteria

Now write your own three single-check criteria for deliverable A. Each needs a signed weight and an explicit/implicit label. Example: “States Bo’s full-trip total as exactly 50 credits, including 10 credits for the shuttle” (+2, explicit). Choose three different checks and explain their stakes.

Below, write one criterion at a time. Hints flag possible issues with wording; they do not understand meaning or decide PASS/FAIL. After one revision, use the self-review checklist and examples to improve your draft.

Criterion 1 of 3

Write one criterion for the fictional itinerary

Targeted self-review

Independent exercise: fictional deliverable C swaps A’s Monday and Tuesday attractions: Monday Glass Garden; Tuesday Lantern Hall; Wednesday Cloud Walk. All lunches are Moss Kitchen and totals remain 50 per traveler, 100 party. Score your three criteria against C. Record PASS/FAIL and the exact evidence, then rewrite one weak criterion. For a negative criterion, PASS means its mistake occurred.

Independent exercise feedback

Glass Garden is closed Monday. Under the eight-check teaching rubric, the Monday Lantern Hall and Tuesday Glass Garden checks both fail; the other positive checks pass and the meal penalty does not trigger. Score: 18 − 3 − 3 = 12. Your three-criterion score depends on the checks you wrote; explain each outcome rather than borrowing this total.

Takeaway: a useful rubric makes a specific failure visible and supports a concrete revision.

Finish with a fresh scenario and scoring record

The worked examples above stay available for learning. This final exercise varies the fictional vendors, prices and mistakes each time you open the page. Calculate the totals, then score three checks one at a time. The generated arithmetic and answer key follow fixed rules. You have two assessment attempts, each with four checks; the practice target is 4/4. After the first unsuccessful attempt, study feedback tied to your scenario before trying a changed response with the same facts. After a second unsuccessful attempt, explore further study and a path based on the checks you answered well. These are learning suggestions, not job-suitability decisions.

Use the record with PIXIE in the classroom pilot

Ask PIXIE: “Help me review my synthetic scenario, three criteria, first-attempt answers, retries, final scoring and one improvement. Identify the next useful practice step. Keep it as a draft for my existing Training checkpoint.” The download includes the scenario facts and scoring history so a new session can use evidence rather than memory.

Review visibility and attribution before sharing through your existing GitHub issue. The record is learner-reported practice; PIXIE does not automatically receive, save or verify classroom progress. Keep private learner and academic records out of public checkpoints. No new classroom tracker is created.

Keep a shareable practice artifact

Use the synthetic practice-artifact template. Include your prompt, input facts, three criteria, weights, classifications, fictional response, PASS/FAIL evidence and one improvement made after feedback.

Your work stays in this browser’s Library after the local save commits. Reset changes your active draft. Export a ZIP from Library for a portable backup; Markdown is a separate practice record. Do not enter personal, employment, medical, academic or financial records.

Sharing is optional. Follow the existing contribution and portfolio workflow. Review your artifact and attribution before submitting through your own GitHub account: issues and comments are public. Keep your existing checkpoint URL and add progress there; an exercise is not maintainer acceptance, certification or membership.

\ No newline at end of file diff --git a/docs/ai-literacy/rubrics.js b/docs/ai-literacy/rubrics.js index 50186ae..0a34251 100644 --- a/docs/ai-literacy/rubrics.js +++ b/docs/ai-literacy/rubrics.js @@ -1,4 +1,4 @@ -/* Deterministic local practice. No model calls, network, or saved learner records. */ +/* Deterministic local practice. No model calls or network. Creative Filing observes local changes. */ (() => { 'use strict'; const questions = [ @@ -140,6 +140,21 @@ document.getElementById('reset-writer').addEventListener('click', resetDrafts); const resetAllDrafts = document.createElement('button'); resetAllDrafts.type = 'button'; resetAllDrafts.textContent = 'Reset my drafts'; resetAllDrafts.addEventListener('click', resetDrafts); document.getElementById('written-criteria').after(resetAllDrafts); +window.trainingRubric = { + snapshot: () => ({drafts, form:[text.value,weight.value,origin.value,reason.value], feedback:feedback.textContent, reviews, answers:Array.from(document.querySelectorAll('.rubric-question input:checked')).map(i=>({name:i.name,value:i.value})), score:document.getElementById('score-answer').value}), + restore: (state, savedContent) => { + drafts=state.drafts.slice(0,3);reviews=0;writer.reset(); + [text.value,weight.value,origin.value,reason.value]=state.form; + feedback.textContent=state.feedback||''; + const list=document.getElementById('written-criteria');list.replaceChildren(); + drafts.forEach((d,i)=>{const p=document.createElement('p');p.textContent='Draft '+(i+1)+': '+d.criterion+' ('+d.weight+', '+d.origin+'). Reason: '+d.reason;list.append(p);}); + if(savedContent)for(const result of savedContent.gradingResults){const p=document.createElement('p');p.textContent='Learner-produced grading: '+result.outcome+'; '+(result.points===null?'ungraded':result.points+' signed points')+'. Evidence: '+result.evidence;list.append(p);} + writer.hidden=drafts.length===3;keep.hidden=true;review.disabled=false; + document.getElementById('writer-progress').textContent='Criterion '+Math.min(3,drafts.length+1)+' of 3'; + for(const a of state.answers||[])for(const input of document.querySelectorAll('.rubric-question input'))if(input.name===a.name&&input.value===a.value)input.checked=true; + document.getElementById('score-answer').value=state.score||''; + } +}; document.getElementById('download-drafts').addEventListener('click', () => { const sourceExercise = "Synthetic task prompt\nCreate an itinerary for two travelers, Ari and Bo, for Monday 8 June through Wednesday 10 June in fictional year F1. Include one attraction and one lunch each day, use each attraction once, and use only the supplied vendors. Both travelers require nut-free meals. Show each traveler\u2019s total and the party total, including the shared shuttle. Budget: at most 100 credits for the party.\nSynthetic input facts\nGlass Garden: 10 credits per person; closed Monday.\nLantern Hall: 15 credits per person; closed Wednesday.\nCloud Walk: free; unavailable Tuesday (a blackout date).\nMoss Kitchen: nut-free lunch, 5 credits per person; open all three days.\nEmber Caf\u00e9: lunch, 4 credits per person; contains nuts. It cannot meet this task\u2019s meal requirement.\nOne shuttle pass for all three days costs 20 credits for the party. Allocate 10 credits to each traveler.\nNo other costs, taxes, tips, discounts, travel-time constraints or reservations apply. Each attraction and lunch takes place within that day.\nComplete fictional deliverable A\nMonday 8 June: Lantern Hall; lunch at Moss Kitchen.\nTuesday 9 June: Glass Garden; lunch at Moss Kitchen.\nWednesday 10 June: Cloud Walk; lunch at Moss Kitchen.\nAri: 10 + 15 + 0 + (3 \u00d7 5) + 10 = 50 credits.\nBo: 10 + 15 + 0 + (3 \u00d7 5) + 10 = 50 credits.\nParty: 50 + 50 = 100 credits, including the shared 20-credit shuttle.\nAttractions: 25 per person, 50 party. Lunches: 15 per person, 30 party. Shuttle: 10 per person, 20 party. Total: 50 per person, 100 party."; const content = '# Synthetic AI rubric practice artifact\n\nExercise only; not certification or accepted project work.\n\n## Synthetic prompt and input facts\n' + sourceExercise + '\n\n## My three criteria\n' + drafts.map((d,i) => (i+1)+'. '+d.criterion+'\nWeight: '+d.weight+'; '+d.origin+'\nReason: '+d.reason+'\nPASS/FAIL on C: [fill in]\nExact evidence: [fill in]\n').join('\n') + '\n## Fictional deliverable\nDeliverable C: Monday Glass Garden; Tuesday Lantern Hall; Wednesday Cloud Walk. Moss Kitchen lunch daily. Printed totals: Ari 50 credits; Bo 50 credits; party 100 credits including the shared 20-credit shuttle. Glass Garden is closed Monday.\n\n## Scoring\nFor each PASS add its signed weight; FAIL adds zero. Total: [calculate]\n\n## One improvement after feedback\nBefore: [fill in]\nAfter: [fill in]\nWhy: [fill in]\n\nReview all placeholders and public visibility before sharing.\n'; diff --git a/docs/training.css b/docs/training.css index d35bc18..b024163 100644 --- a/docs/training.css +++ b/docs/training.css @@ -51,3 +51,10 @@ button:disabled{cursor:default;border-style:dashed} .feedback{margin-block:1rem}.answer-key{margin-top:1rem}.answer-key summary{cursor:pointer;color:var(--the-light);padding-block:.75rem;min-height:3rem} [hidden]{display:none!important} select{max-width:100%} +/* Creative Filing reuses shared reading controls and palette. */ +input[type=search],input[type=text],input:not([type]){display:block;width:100%;min-height:3rem;padding:.65rem;margin-block:.5rem 1rem;background:var(--void);color:var(--text-primary);border:1px solid var(--line);border-radius:.5rem} +.filing-record{border-block-start:1px solid var(--line);padding-block:1.5rem}.filing-record h3 button{text-align:start;overflow-wrap:anywhere;max-width:100%}.filing-record details>button,.filing-record details>.button{margin:.5rem .5rem .5rem 0} +#label-management details>summary,.filing-record summary{min-height:3rem;padding-block:.6rem;cursor:pointer}dialog{background:var(--surface);color:var(--text-primary);border:2px solid var(--line);border-radius:.75rem;padding:1.5rem;width:min(46rem,calc(100% - 2rem));max-height:90vh;overflow:auto}dialog::backdrop{background:#000a}dialog label{display:block;margin-block:1rem}dialog select{display:block;max-width:100%;width:100%}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit}.filing-record .choice{min-height:3rem} +.display-controls>label{min-width:0;width:min(100%,24rem)}.display-controls select{min-width:0;max-width:100%;width:100%} +.filing-library input,.filing-library button,.filing-library select{max-width:100%;min-width:0;overflow-wrap:anywhere}.filing-library input[type=file]{width:100%;white-space:normal}.filing-library fieldset{min-inline-size:0}#label-views button{text-align:start} +.filing-library input[type=file]{min-height:3rem}.filing-library input[type=file]::file-selector-button{font:inherit;min-height:3rem;max-width:100%;background:var(--surface);color:var(--text-primary);border:1px solid var(--line);border-radius:.5rem;padding:.5rem} diff --git a/package.json b/package.json index ed66378..fc61c88 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "scripts": { "test": "jest", "test:watch": "jest --watch", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:filing": "node scripts/test-creative-filing.mjs", + "test:filing:browser": "node scripts/test-creative-filing-browser.cjs" }, "engines": { "node": ">=20" diff --git a/scripts/test-creative-filing-browser.cjs b/scripts/test-creative-filing-browser.cjs new file mode 100644 index 0000000..853bea4 --- /dev/null +++ b/scripts/test-creative-filing-browser.cjs @@ -0,0 +1,425 @@ +const { chromium } = require("playwright"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const base = process.env.BASE_URL || "http://127.0.0.1:8765"; +(async () => { + const browser = await chromium.launch({ + executablePath: process.env.BROWSER_EXECUTABLE || "/usr/bin/chromium", + args: ["--no-sandbox"], + }); + const context = await browser.newContext({ acceptDownloads: true }); + context.setDefaultTimeout(10000); + const page = await context.newPage(); + const errors = [], + network = []; + page.on("pageerror", (e) => errors.push(e.message)); + context.on("request", (r) => { + if ( + !["GET", "HEAD"].includes(r.method()) || + new URL(r.url()).origin !== new URL(base).origin + ) + network.push(r.method() + " " + r.url()); + }); + const saved = () => + page.waitForFunction( + () => + document.querySelector("#filing-status")?.textContent === + "Saved on this device", + ); + const library = async () => { + await page.goto(base + "/ai-literacy/library.html"); + await page.waitForFunction(() => + document + .querySelector("#library-status") + .textContent.includes("Library loaded"), + ); + }; + const snapshot = () => + page.evaluate(async () => { + const { openStore } = await import("./filing/store.mjs"); + return openStore().read(); + }); + await page.goto(base + "/ai-literacy/rubrics.html"); + await page.waitForTimeout(150); + assert.equal((await snapshot()).artifacts.length, 0); + await page + .locator("#criterion-text") + .fill("States the synthetic total as 100 credits."); + await page.locator("#criterion-reason").fill("The prompt asks for costs."); + await saved(); + let state = await snapshot(); + assert.equal(state.artifacts.length, 1); + const id = state.artifacts[0].id; + await page.reload(); + await page.waitForFunction(() => + document.querySelector("#criterion-text").value.includes("100 credits"), + ); + assert.equal((await snapshot()).artifacts[0].id, id); + await page.locator("#filing-title").click(); + await page.getByLabel("Name", { exact: true }).fill("Cancel name"); + await page.keyboard.press("Escape"); + assert.notEqual( + await page.locator("#filing-title").innerText(), + "Cancel name", + ); + assert.equal( + await page + .locator("#filing-title") + .evaluate((e) => e === document.activeElement), + true, + ); + await page.locator("#filing-title").click(); + await page.getByLabel("Name", { exact: true }).fill("Synthetic café"); + await page.keyboard.press("Enter"); + await saved(); + await library(); + assert.match(await page.locator("#recent").innerText(), /Synthetic café/); + let actions = page.locator("#recent article details"); + if (!(await actions.evaluate((e) => e.open))) + await actions.locator("summary").click(); + await actions + .getByLabel("Needs review for Synthetic café", { exact: true }) + .check(); + actions = page.locator("#recent article details"); + if (!(await actions.evaluate((e) => e.open))) + await actions.locator("summary").click(); + await actions + .getByLabel("In progress for Synthetic café", { exact: true }) + .check(); + await page.locator("#find").fill("CAFÉ review"); + await page.waitForTimeout(250); + assert.equal(await page.locator("#recent article").count(), 1); + await page + .locator("#label-views") + .getByRole("button", { name: "Verified", exact: true }) + .click(); + assert.equal(await page.locator("#recent article").count(), 0); + await page.locator("#clear").click(); + await page.locator("#label-word").fill("Unused custom"); + await page.getByRole("button", { name: "Create label", exact: true }).click(); + await page.waitForFunction(() => + document + .querySelector("#label-views") + .textContent.includes("Unused custom"), + ); + const before = await snapshot(); + const dp = page.waitForEvent("download"); + await page.locator("#export-all").click(); + const dl = await dp; + const file = await dl.path(); + const backup = fs.readFileSync(file); + assert(backup.length > 0); + await page.locator("#import-file").setInputFiles({ + name: "roundtrip.zip", + mimeType: "application/zip", + buffer: backup, + }); + await page.locator("#import-preview").waitFor({ state: "visible" }); + assert.match(await page.locator("#import-content").innerText(), /1 skipped/); + await page.locator("#import-cancel").click(); + assert.deepEqual(await snapshot(), before); + + // A library mutation after preview must stop the import and require a new confirmation. + await page + .locator("#import-file") + .setInputFiles({ + name: "stale-preview.md", + mimeType: "text/markdown", + buffer: Buffer.from("# Synthetic concurrent import"), + }); + await page.locator("#import-preview").waitFor({ state: "visible" }); + await page.evaluate(async () => { + const { openStore } = await import("./filing/store.mjs"); + const store = openStore(), + s = await store.read(); + s.labels.push({ id: crypto.randomUUID(), word: "Concurrent tab label" }); + await store.commit(s.revision, s); + }); + await page.locator("#import-confirm").click(); + await page.waitForFunction(() => + document + .querySelector("#import-status") + .textContent.includes("Library changed since preview"), + ); + assert.equal((await snapshot()).artifacts.length, before.artifacts.length); + await page.locator("#import-confirm").click(); + await page.waitForFunction(() => + document + .querySelector("#library-status") + .textContent.includes("Import committed"), + ); + // Quota failure retains the old durable revision; explicit export and retry recover. + const editPage = await context.newPage(); + await editPage.goto(base + "/ai-literacy/rubrics.html?artifact=" + id); + await editPage.waitForFunction( + () => + document.querySelector("#filing-status").textContent === + "Saved on this device", + ); + await editPage.evaluate(() => { + window.realPut = IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put = function () { + throw new DOMException("Synthetic quota failure", "QuotaExceededError"); + }; + }); + await editPage + .locator("#criterion-text") + .fill("Unsaved synthetic quota draft"); + await editPage.waitForFunction( + () => + document.querySelector("#filing-status").textContent === "Could not save", + ); + assert.equal( + await editPage.locator("#criterion-text").inputValue(), + "Unsaved synthetic quota draft", + ); + const oldDurable = await snapshot(); + assert.notEqual( + oldDurable.artifacts[0].content.revisionNotes[0].before, + "Unsaved synthetic quota draft", + ); + const draftDownload = editPage.waitForEvent("download"); + await editPage.locator("#filing-export").click(); + assert.match((await draftDownload).suggestedFilename(), /\.zip$/); + await editPage.evaluate( + () => (IDBObjectStore.prototype.put = window.realPut), + ); + await editPage.locator("#filing-retry").click(); + await editPage.waitForFunction( + () => + document.querySelector("#filing-status").textContent === + "Saved on this device", + ); + await editPage.close(); + // Real IndexedDB atomic stale revision / transaction abort checks. + const fault = await page.evaluate(async () => { + const { openStore } = await import("./filing/store.mjs"); + const s = openStore(), + one = await s.read(), + two = await s.read(); + one.labels[0].word = "Renamed review"; + await s.commit(one.revision, one); + let stale = false; + try { + await s.commit(two.revision, two); + } catch { + stale = true; + } + const old = await s.read(); + const original = IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put = function (...args) { + const request = original.apply(this, args); + this.transaction.abort(); + return request; + }; + let aborted = false; + try { + const next = structuredClone(old); + next.labels.push({ id: crypto.randomUUID(), word: "Should not commit" }); + await s.commit(old.revision, next); + } catch { + aborted = true; + } finally { + IDBObjectStore.prototype.put = original; + } + return { + stale, + aborted, + unchanged: JSON.stringify(old) === JSON.stringify(await s.read()), + }; + }); + assert.deepEqual(fault, { stale: true, aborted: true, unchanged: true }); + // Restore into a separate browser identity / origin store without overwriting owner data. + const fresh = await browser.newContext(); + const p = await fresh.newPage(); + await p.goto(base + "/ai-literacy/library.html"); + await p.waitForFunction(() => + document + .querySelector("#library-status") + .textContent.includes("Library loaded"), + ); + await p.locator("#import-file").setInputFiles({ + name: "restore.zip", + mimeType: "application/zip", + buffer: backup, + }); + await p.locator("#import-preview").waitFor({ state: "visible" }); + await p.locator("#import-confirm").click(); + await p.waitForFunction(() => + document + .querySelector("#library-status") + .textContent.includes("Import committed"), + ); + const restored = await p.evaluate(async () => { + const { openStore } = await import("./filing/store.mjs"); + return openStore().read(); + }); + assert.deepEqual(restored.artifacts[0].content, before.artifacts[0].content); + assert.deepEqual(restored.artifacts[0].labels, before.artifacts[0].labels); + assert.equal(restored.labels.at(-1).word, "Unused custom"); + await p.reload(); + await p.waitForFunction(() => + document.querySelector("#recent").textContent.includes("Synthetic café"), + ); + await p.locator("#recent details summary").click(); + await p.getByRole("link", { name: "Resume Synthetic café" }).click(); + await p.waitForFunction(() => + document.querySelector("#criterion-text").value.includes("100 credits"), + ); + // Capstone actual answer snapshot and resume, preserving the existing grader. + await p.locator("#capstone-new").click(); + await p.waitForTimeout(150); + await p.locator("#capstone-controls input[value=PASS]").check(); + await p.locator("#capstone-submit").click(); + await p.waitForFunction( + () => + document.querySelector("#filing-status").textContent === + "Saved on this device", + ); + const capId = new URL(p.url()).searchParams.get("artifact"); + await p.reload(); + await p.waitForFunction(() => + document + .querySelector("#capstone-question") + .textContent.includes("check 2 of 3"), + ); + assert.equal(new URL(p.url()).searchParams.get("artifact"), capId); + // Preserve a completed failed first attempt and incomplete second attempt in native ZIP. + for (let step = 2; step <= 4; step++) { + const snap = await p.evaluate(() => window.trainingCapstone.snapshot()); + const exercise = snap.attempts.at(-1).exercise; + if (step < 4) + await p + .locator( + "#capstone-controls input[value=" + + (exercise.criteria[step - 1].pass ? "FAIL" : "PASS") + + "]", + ) + .check(); + else await p.locator("#capstone-total").fill(String(exercise.score + 1)); + await p.locator("#capstone-submit").click(); + await p.locator("#capstone-next").click(); + } + await p.locator("#capstone-retry").click(); + for (let step = 1; step <= 2; step++) { + await p.locator("#capstone-controls input[value=PASS]").check(); + await p.locator("#capstone-submit").click(); + await p.locator("#capstone-next").click(); + } + await p.waitForFunction( + () => + document.querySelector("#filing-status").textContent === + "Saved on this device", + ); + const twoRounds = await p.evaluate(async (id) => { + const { openStore } = await import("./filing/store.mjs"), + { exportArchive, readArchive } = await import("./filing/archive.mjs"); + const s = await openStore().read(), + record = s.artifacts.find((a) => a.id === id), + archive = await exportArchive(s, id), + source = await readArchive(archive.data, "two-attempts.zip"); + return { + same: + JSON.stringify(source.artifacts[0].content) === + JSON.stringify(record.content), + status: record.content.status, + answers: record.content.attempts.map((a) => a.answers.length), + ungraded: record.content.gradingResults.every( + (r) => r.outcome === "ungraded" && r.points === null, + ), + }; + }, capId); + assert.deepEqual(twoRounds, { + same: true, + status: "draft", + answers: [4, 2], + ungraded: true, + }); + // Legacy exact bytes and untrusted text rendering. + await library(); + const legacy = Buffer.from( + "# Synthetic\r\n\r\n", + ); + await page.locator("#import-file").setInputFiles({ + name: "legacy.md", + mimeType: "text/markdown", + buffer: legacy, + }); + await page.locator("#import-preview").waitFor({ state: "visible" }); + await page.locator("#import-confirm").click(); + await page.waitForFunction(() => + document + .querySelector("#library-status") + .textContent.includes("Import committed"), + ); + const legacyRow = page.locator("article").filter({ hasText: "legacy.md" }); + await legacyRow.locator("summary").click(); + await legacyRow + .getByRole("button", { name: "Read original Markdown" }) + .click(); + assert.equal(await page.evaluate(() => window.pwned), undefined); + const mdp = page.waitForEvent("download"); + await legacyRow + .getByRole("button", { name: "Download original Markdown" }) + .click(); + assert.deepEqual(fs.readFileSync(await (await mdp).path()), legacy); + // Reflow at doubled base text size and both shared themes, no page overflow. + for (const theme of ["dark", "light"]) + for (const width of [320, 768, 1024]) { + await page.setViewportSize({ width, height: 900 }); + await page.evaluate((theme) => { + document.documentElement.dataset.theme = theme; + document.documentElement.style.fontSize = "200%"; + }, theme); + assert( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + theme + " " + width, + ); + } + + if (process.env.AXE_PATH) { + await page.setViewportSize({ width: 1024, height: 900 }); + for (const theme of ["dark", "light"]) { + await page.evaluate((theme) => { + document.documentElement.style.fontSize = ""; + document.documentElement.dataset.theme = theme; + }, theme); + await page.addScriptTag({ path: process.env.AXE_PATH }); + const scan = await page.evaluate(() => + axe.run(document, { + runOnly: ["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"], + }), + ); + assert.deepEqual( + scan.violations.map((v) => ({ + id: v.id, + nodes: v.nodes.map((n) => n.target), + })), + [], + theme + " axe", + ); + } + console.log("PASS axe WCAG A/AA on populated Library in both themes."); + } + const out = process.env.ARTIFACT_DIR || "/tmp/creative-filing-review"; + fs.mkdirSync(out, { recursive: true }); + await page.setViewportSize({ width: 1024, height: 900 }); + await page.screenshot({ + path: path.join(out, "library.png"), + fullPage: true, + }); + assert.deepEqual(errors, []); + assert.deepEqual(network, []); + await fresh.close(); + await context.close(); + await browser.close(); + console.log( + "PASS browser: RT-01..12 and NEG-01/03/07/09 automated portions: real IndexedDB, input resume, keyboard rename, labels/search, download, atomic import, isolated restore, capstone, legacy text, no artifact requests, doubled text/reflow. Physical iPad/VoiceOver NOT RUN.", + ); +})().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/test-creative-filing.mjs b/scripts/test-creative-filing.mjs new file mode 100644 index 0000000..86388a3 --- /dev/null +++ b/scripts/test-creative-filing.mjs @@ -0,0 +1,409 @@ +import assert from "node:assert/strict"; +import { + create, + initial, + validate, + clone, + uuid, + find, + hash, +} from "../docs/ai-literacy/filing/model.mjs"; +import { + exportArchive, + readArchive, + planImport, + zip, + unzip, +} from "../docs/ai-literacy/filing/archive.mjs"; +import { Draft } from "../docs/ai-literacy/filing/store.mjs"; +import { zipSync } from "../docs/ai-literacy/filing/vendor/fflate.mjs"; +const text = new TextEncoder(); +const content = { + type: "practice/structured-v1", + taskPrompt: "Synthetic task", + inputFacts: ["Fictional facts"], + fictionalDeliverable: "Fictional response", + criteria: [ + { + id: "c1", + text: "Exact total", + weight: 4, + origin: "explicit", + reason: "Task", + }, + { + id: "c2", + text: "Defined mistake", + weight: -6, + origin: "explicit", + reason: "Penalty", + }, + ], + gradingResults: [ + { criterionId: "c1", outcome: "PASS", evidence: "Synthetic 4", points: 4 }, + { + criterionId: "c2", + outcome: "PASS", + evidence: "Synthetic -6", + points: -6, + }, + ], + revisionNotes: [], + status: "draft", +}; +const a = create( + { id: "rubric-writing", title: "Rubric writing", version: "1" }, + content, + ), + state = initial(); +state.artifacts.push(a); +a.labels = state.labels.slice(0, 2).map((l) => l.id); +validate(state); +let passed = 0; +async function test(id, name, run) { + await run(); + passed++; + console.log("PASS " + id + " " + name); +} +await test("RT-01", "draft identity and commit-only status", async () => { + let complete; + const store = { + save: async (record) => { + await new Promise((r) => (complete = r)); + return { ...state, artifacts: [record] }; + }, + }; + const events = [], + d = new Draft(store, a, 0, (s) => events.push(s)); + d.edit(a); + const saving = d.flush(); + await Promise.resolve(); + assert.equal(events.at(-1), "Saving"); + complete(); + await saving; + assert.equal(events.at(-1), "Saved on this device"); +}); +await test("RT-02", "signed points survive archive round trip", async () => { + const restored = await readArchive( + (await exportArchive(state, a.id)).data, + "one.zip", + ); + assert.deepEqual(restored.artifacts[0].content, content); + assert.equal( + restored.artifacts[0].content.gradingResults.reduce( + (n, r) => n + r.points, + 0, + ), + -2, + ); +}); +await test("RT-03", "rename preserves identity/content", () => { + const b = clone(a); + b.name = "Renamed"; + assert.equal(b.id, a.id); + assert.deepEqual(b.content, a.content); +}); +await test("RT-04", "two stable labels and rename", () => { + const s = clone(state); + s.labels[0].word = "Review later"; + assert.equal(find(s, "review later").length, 1); + assert.equal(s.artifacts.length, 1); +}); +await test("RT-05", "NFC, tokens and label intersection", () => { + const s = clone(state); + s.artifacts[0].name = "CAFÉ synthetic"; + assert.equal(find(s, "cafe\u0301 review", s.labels[0].id).length, 1); + assert.equal(find(s, "CAFÉ", s.labels[3].id).length, 0); +}); +await test( + "RT-06", + "single ZIP manifest hashes and referenced labels", + async () => { + const archive = await exportArchive(state, a.id); + const files = unzip(archive.data); + assert.equal(files.size, 3); + const source = await readArchive(archive.data, "single.zip"); + assert.equal(source.labels.length, 2); + assert.match(archive.name, /^pixie-.*\.zip$/); + const compressed = zipSync(Object.fromEntries(files), { level: 6 }); + assert.deepEqual( + (await readArchive(compressed, "deflate.zip")).artifacts[0].content, + content, + ); + }, +); +await test( + "RT-07", + "same UUID skip and restore into empty library", + async () => { + const src = await readArchive( + (await exportArchive(state, a.id)).data, + "one.zip", + ); + assert.equal(planImport(src, state).summary.skipped, 1); + assert.equal(planImport(src, initial()).summary.added, 1); + }, +); +await test("RT-08", "deep structured content remains identical", async () => { + const src = await readArchive((await exportArchive(state)).data, "all.zip"); + const plan = planImport(src, initial()); + assert.deepEqual(plan.next.artifacts[0].content, a.content); + assert.deepEqual(plan.next.artifacts[0].labels, a.labels); +}); +await test("RT-09", "all archive retains unused custom labels", async () => { + const s = clone(state); + s.labels.push({ id: uuid(), word: "Unused custom" }); + const src = await readArchive((await exportArchive(s)).data, "all.zip"); + assert.equal(src.labels.at(-1).word, "Unused custom"); +}); +await test( + "RT-10", + "legacy exact CRLF/BOM and duplicate source skip", + async () => { + const input = text.encode("\ufeff# Draft\r\nSynthetic incomplete\r\n"); + const src = await readArchive(input, "original.md"); + assert.deepEqual(text.encode(src.artifacts[0].content.text), input); + const p = planImport(src, initial()); + assert.equal( + planImport(await readArchive(input, "renamed.md"), p.next).summary + .skipped, + 1, + ); + const again = await readArchive( + (await exportArchive(p.next)).data, + "legacy.zip", + ); + assert.equal(again.artifacts[0].content.status, "draft"); + assert.deepEqual(text.encode(again.artifacts[0].content.text), input); + }, +); +await test("RT-11", "core has no network/account API", async () => { + const fs = await import("node:fs/promises"); + for (const f of ["model", "store", "archive"]) + assert.doesNotMatch( + await fs.readFile( + new URL("../docs/ai-literacy/filing/" + f + ".mjs", import.meta.url), + "utf8", + ), + /fetch\(|XMLHttpRequest|sendBeacon|WebSocket/, + ); +}); +await test("RT-12", "semantic keyboard/reading controls contract", async () => { + const fs = await import("node:fs/promises"), + html = await fs.readFile( + new URL("../docs/ai-literacy/library.html", import.meta.url), + "utf8", + ); + assert.match(html, / { + const d = new Draft( + { + save: async () => { + throw new DOMException("Quota", "QuotaExceededError"); + }, + }, + a, + 1, + ); + d.edit({ ...a, name: "Working" }); + assert.equal(await d.flush(), false); + assert.equal(d.record.name, "Working"); + assert.equal(d.durable.name, a.name); + }, +); +await test( + "NEG-02", + "uncommitted draft is explicitly identified in archive", + async () => { + const data = ( + await exportArchive(state, a.id, "includes-uncommitted-draft") + ).data; + const src = await readArchive(data, "draft.zip"); + assert.equal(src.persistence, "includes-uncommitted-draft"); + }, +); +await test( + "NEG-03", + "changed UUID conflicts, default skip, explicit separate copy", + async () => { + const src = await readArchive((await exportArchive(state)).data, "all.zip"); + const changed = clone(state); + changed.artifacts[0].content.taskPrompt = "Locally edited"; + assert.equal(planImport(src, changed).issues[0].type, "artifact"); + assert.equal( + planImport(src, changed, { ["artifact:" + a.id]: "skip" }).next.artifacts + .length, + 1, + ); + const p = planImport(src, changed, { ["artifact:" + a.id]: "copy" }); + assert.equal(p.next.artifacts.length, 2); + assert.notEqual(p.next.artifacts[1].id, a.id); + assert.equal( + planImport(src, p.next, { ["artifact:" + a.id]: "copy" }).next.artifacts + .length, + 2, + ); + }, +); +await test( + "NEG-04", + "truncated, hash, duplicate path and schema rejected", + async () => { + const data = (await exportArchive(state)).data; + await assert.rejects(readArchive(data.slice(0, -1), "x.zip")); + const files = unzip(data); + const altered = [...files].map(([p, b]) => [ + p, + p === "labels.json" ? text.encode("{}") : b, + ]); + await assert.rejects(readArchive(zip(altered), "x.zip")); + await assert.rejects( + readArchive(zip([...files, ...[...files].slice(0, 1)]), "x.zip"), + ); + const m = JSON.parse(new TextDecoder().decode(files.get("manifest.json"))); + m.schemaVersion = 9; + files.set("manifest.json", text.encode(JSON.stringify(m))); + await assert.rejects(readArchive(zip([...files]), "future.zip")); + }, +); +await test("NEG-05", "same names are separate identities", () => { + const s = clone(state), + b = clone(a); + b.id = uuid(); + s.artifacts.push(b); + validate(s); + assert.equal(find(s, a.name).length, 2); +}); +await test( + "NEG-06", + "missing label requires explicit repair and conflicting word requires mapping", + async () => { + const src = await readArchive((await exportArchive(state)).data, "all.zip"); + src.labels = src.labels.filter((l) => l.id !== a.labels[0]); + assert.equal(planImport(src, initial()).issues[0].type, "missing-label"); + assert.doesNotThrow(() => + planImport(src, initial(), { ["label:" + a.labels[0]]: "omit" }), + ); + const full = await readArchive( + (await exportArchive(state)).data, + "all.zip", + ); + full.labels[0].word = "Different"; + assert.equal(planImport(full, initial()).issues[0].type, "label"); + assert.equal( + planImport(full, initial(), { ["label:" + a.labels[0]]: "existing" }) + .issues.length, + 0, + ); + const copied = planImport(full, initial(), { + ["label:" + a.labels[0]]: "copy", + }); + const retried = planImport(full, copied.next, { + ["label:" + a.labels[0]]: "copy", + }); + assert.equal(retried.next.artifacts.length, copied.next.artifacts.length); + assert.equal(retried.next.labels.length, copied.next.labels.length); + const unused = { ...full, artifacts: [] }; + const labelsOnly = planImport(unused, initial(), { + ["label:" + a.labels[0]]: "copy", + }); + assert.equal( + planImport(unused, labelsOnly.next, { ["label:" + a.labels[0]]: "copy" }) + .next.labels.length, + labelsOnly.next.labels.length, + ); + }, +); +await test( + "NEG-07", + "staging never mutates existing library, retry idempotent", + async () => { + const before = JSON.stringify(state), + src = await readArchive((await exportArchive(state)).data, "all.zip"); + planImport(src, state); + assert.equal(JSON.stringify(state), before); + const p = planImport(src, initial()); + assert.equal(planImport(src, p.next).summary.added, 0); + }, +); +await test("NEG-08", "reset recovery from archive", async () => { + const backup = (await exportArchive(state)).data; + const empty = initial(); + assert.equal(empty.artifacts.length, 0); + assert.deepEqual( + planImport(await readArchive(backup, "backup.zip"), empty).next.artifacts[0] + .content, + a.content, + ); +}); +await test("NEG-09", "save conflict retains working copy", async () => { + const d = new Draft( + { + save: async () => { + throw new Error("Save conflict"); + }, + }, + a, + 1, + ); + d.edit({ ...a, name: "Second tab" }); + assert.equal(await d.flush(), false); + assert.equal(d.record.name, "Second tab"); +}); +await test( + "NEG-10", + "unsafe paths, invalid weight, nonfinite and binary Markdown rejected", + async () => { + await assert.rejects( + readArchive( + zip([ + ["../manifest.json", text.encode("{}")], + ["labels.json", text.encode("{}")], + ]), + "unsafe.zip", + ), + ); + for (const weight of [0, Infinity, 11]) { + const s = clone(state); + s.artifacts[0].content.criteria[0].weight = weight; + assert.throws(() => validate(s)); + } + const bomb = zipSync( + { + "manifest.json": new Uint8Array(20 * 1024 * 1024 + 1), + "labels.json": text.encode("{}"), + }, + { level: 9 }, + ); + await assert.rejects(readArchive(bomb, "bomb.zip")); + const invalid = clone(state); + invalid.artifacts[0].content.gradingResults[0].points = 99; + assert.throws(() => validate(invalid)); + await assert.rejects(readArchive(new Uint8Array([255]), "x.md")); + await assert.rejects(readArchive(text.encode("a\0b"), "x.md")); + }, +); +await test( + "NEG-11", + "storage mode has no guaranteed-retention promise", + async () => { + const fs = await import("node:fs/promises"), + html = await fs.readFile( + new URL("../docs/ai-literacy/library.html", import.meta.url), + "utf8", + ); + assert.match(html, /Private Browsing/); + assert.match(html, /cleared or evicted/); + assert.match(html, /Home\s+Screen/); + }, +); +console.log( + passed + + " automated contract checks passed. Physical iPad/Files/VoiceOver acceptance NOT RUN.", +);