From d3f3171b967073953ae155320073a30748b082a6 Mon Sep 17 00:00:00 2001 From: Norbert Kozsir Date: Tue, 4 Aug 2026 22:55:59 +0200 Subject: [PATCH 1/2] Simplify public README --- README.md | 172 +++++++++++++----------------------------------------- 1 file changed, 42 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index 8b734b0..4c44fed 100644 --- a/README.md +++ b/README.md @@ -1,132 +1,44 @@ # Campus Extensions -This repository is the public, review-first registry for extensions distributed -by FlutterFlow Campus. Campus configures this Git repository as its official -source by default. The catalog points at immutable, content-addressed -`.campusext` archives committed to this repository. - -The trust model is deliberately close to Raycast's extension store: - -- extension source changes arrive through reviewed pull requests; -- required checks validate manifests, packages, and the complete catalog; -- publication happens in trusted CI after source review, through a second, - generated pull request; -- clients fetch a particular Git commit, require fast-forward history, and - verify every artifact's exact length and SHA-256 digest before installation; -- no registry-specific signature is layered on top of Git provenance and - content addressing; -- native helpers use the v2 package format and must additionally be built in - trusted CI and carry FlutterFlow's Developer ID signature and Apple - notarization. - -Installing an extension still means choosing to run reviewed third-party code. -A declared resource request is an authority ceiling and an install-review -surface, not a sandbox promise. In particular, a granted raw process or native -helper runs as the current user and may reach anything that process can reach. - -## Repository contract - -```text -campus.registry.json -schemas/ - campus.registry.v1.schema.json - campus.extension.v3.schema.json -extensions/ - / - campus.extension.json - src/ - assets/ - README.md - CHANGELOG.md -artifacts/sha256/<64-lowercase-hex>.campusext -tool/ -.github/workflows/ -``` - -`campus.registry.json` is canonical JSON: UTF-8, sorted object keys, no -insignificant whitespace, no trailing newline, safe integers only, and packages -strictly sorted by extension ID. Its schema identifier is -`campus.registry.v1`; its registry identity is -`flutterflow.campus-extensions`. - -Each release records: - -- a strictly increasing package-local sequence and unique semantic version; -- active or withdrawn status; -- exact artifact path, byte length, and SHA-256 digest; -- SHA-256 of the manifest bytes inside that artifact; -- Campus/runtime/platform/architecture compatibility; -- the complete review-facing authority summary used to decide whether an - update can be approved automatically. - -The JSON schemas document the wire shape. `tool/registry.py validate` is the -normative repository check because ordering, canonical bytes, cross-file -digests, archive structure, references, and authority projections cannot all be -expressed in JSON Schema. - -## Contributor loop - -Author source under `extensions//`. Keep compiled JavaScript in `src/`; -Campus never runs a package manager or compiler while installing. README, -CHANGELOG, tests, and build files remain authoring material and are excluded -from the runtime archive. - -Run: - -```bash -python3 -m unittest discover -s tool/tests -v -python3 tool/registry.py validate --root . -``` - -Do not hand-edit `campus.registry.json` or add files directly below -`artifacts/sha256/`. After the source PR merges, a maintainer starts the -**Prepare extension publication PR** workflow. Locally, the equivalent command -is: - -```bash -python3 tool/registry.py publish \ - --root . \ - --extension io.flutterflow.campus.example \ - --description "One sentence shown in Extension Center" \ - --minimum-campus-version 1.0.0 \ - --platform macos \ - --architecture arm64 \ - --architecture x86_64 \ - --feature manifest.v3 \ - --feature resources.v3 -``` - -The command validates the source, creates canonical `.campusext` bytes, writes -them only at their digest-addressed path, derives the manifest and authority -digests, inserts the next release sequence, rewrites the catalog canonically, -and validates the whole repository. CI then opens a generated publication PR; -the release is not visible to clients until that PR passes review and merges. - -The dependency-free checked-in publisher emits the runtime's canonical, -source-only `.campusext` v1 format. Campus also supports `.campusext` v2 for -manifest-declared native helpers, but native release bytes must first be built, -Developer-ID signed, notarized, and stapled in trusted CI; the signed digest -must then be written into the schema-v3 manifest before the canonical Campus v2 -packer runs. Until that trusted v2 publication job is wired into this -repository—and the checked-in validator verifies the same v2 bytes— -`tool/native_release_gate.py` refuses a native declaration with that exact -remediation instead of silently emitting an incomplete v1 archive. - -## Client behavior - -The official Campus client uses Git as transport, not as an execution root. It -fetches the accepted commit into a sanitized bare repository, reads only the -catalog and referenced blobs, verifies fast-forward ancestry against the -persisted high-water commit, verifies artifact bytes, and installs them into an -immutable local content-addressed store. It does not check out hooks, resolve -submodules or Git LFS, or execute repository files. - -Campus refreshes the official source at startup, every six hours, and on manual -refresh. Reviewed updates may activate automatically only when the authority -fingerprint is unchanged. Authority changes wait for review. Failed activation -keeps or restores the previous working release. - -## Legal status - -No open-source license is granted for the contents of this repository. See -[NOTICE](NOTICE). +The official extension registry for FlutterFlow Campus. + +Browse and install reviewed extensions from **Campus → Settings → Extensions**. + +## Available extensions + +- [Usage Limits](extensions/io.flutterflow.campus.usage-limits/) — view Claude + Code and Codex usage in one Campus dashboard. + +## Contributing + +Extensions live under `extensions//`. + +1. Fork this repository and create a branch. +2. Add or update an extension under `extensions/`. +3. Run the repository checks: + + ```sh + python3 -m unittest discover -s tool/tests -v + python3 tool/registry.py validate --root . + ``` + +4. Open a pull request. + +Do not edit `campus.registry.json` or files under `artifacts/` by hand. +Maintainers publish reviewed extensions through CI. + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the package structure and review +process. + +## Trust and security + +Extension source and published artifacts are reviewed and validated before they +reach Campus. Campus also shows an extension's requested access before enabling +it. Extensions can run with user-granted permissions, so install only extensions +you trust. + +Please report security issues as described in [SECURITY.md](SECURITY.md). + +## License + +No open-source license is granted for this repository. See [NOTICE](NOTICE). From d49b37b49a6b4bc8a9070a7c898c14365a39d790 Mon Sep 17 00:00:00 2001 From: Norbert Kozsir Date: Wed, 5 Aug 2026 10:31:10 +0200 Subject: [PATCH 2/2] Add X Draft Card source and preview review policy --- .github/CODEOWNERS | 2 + .github/REPOSITORY_SETUP.md | 12 +- .github/workflows/prepare-publication-pr.yml | 10 +- .github/workflows/validate.yml | 2 +- CONTRIBUTING.md | 13 +- README.md | 12 +- SECURITY.md | 4 +- .../README.md | 2 +- .../CHANGELOG.md | 5 + .../README.md | 228 ++++++++ .../campus.extension.json | 125 +++++ .../src/campus.generated.d.mts | 33 ++ .../src/campus.generated.mjs | 33 ++ .../src/main.js | 57 ++ .../src/model.js | 181 +++++++ .../src/render_card.js | 93 ++++ .../src/render_card.tsx | 457 ++++++++++++++++ .../src/render_shared.js | 213 ++++++++ .../src/render_shared.ts | 298 +++++++++++ .../src/x_fidelity_profile.js | 117 +++++ .../src/x_fidelity_profile.ts | 129 +++++ .../src/x_icon_paths.js | 125 +++++ .../src/x_icon_paths.ts | 143 +++++ .../test/twitter_draft_studio.test.mjs | 487 ++++++++++++++++++ .../tsconfig.json | 20 + tool/registry.py | 2 +- tool/tests/test_workflow_security.py | 2 +- 27 files changed, 2778 insertions(+), 27 deletions(-) create mode 100644 extensions/io.flutterflow.campus.x-draft-card/CHANGELOG.md create mode 100644 extensions/io.flutterflow.campus.x-draft-card/README.md create mode 100644 extensions/io.flutterflow.campus.x-draft-card/campus.extension.json create mode 100644 extensions/io.flutterflow.campus.x-draft-card/src/campus.generated.d.mts create mode 100644 extensions/io.flutterflow.campus.x-draft-card/src/campus.generated.mjs create mode 100644 extensions/io.flutterflow.campus.x-draft-card/src/main.js create mode 100644 extensions/io.flutterflow.campus.x-draft-card/src/model.js create mode 100644 extensions/io.flutterflow.campus.x-draft-card/src/render_card.js create mode 100644 extensions/io.flutterflow.campus.x-draft-card/src/render_card.tsx create mode 100644 extensions/io.flutterflow.campus.x-draft-card/src/render_shared.js create mode 100644 extensions/io.flutterflow.campus.x-draft-card/src/render_shared.ts create mode 100644 extensions/io.flutterflow.campus.x-draft-card/src/x_fidelity_profile.js create mode 100644 extensions/io.flutterflow.campus.x-draft-card/src/x_fidelity_profile.ts create mode 100644 extensions/io.flutterflow.campus.x-draft-card/src/x_icon_paths.js create mode 100644 extensions/io.flutterflow.campus.x-draft-card/src/x_icon_paths.ts create mode 100644 extensions/io.flutterflow.campus.x-draft-card/test/twitter_draft_studio.test.mjs create mode 100644 extensions/io.flutterflow.campus.x-draft-card/tsconfig.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0f1e85e..2e655fa 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,5 @@ +# Advisory reviewer routing during the registry preview. Branch protection does +# not require code-owner approval until the permanent review policy is enabled. * @FlutterFlow/prod-eng /campus.registry.json @FlutterFlow/prod-eng /artifacts/ @FlutterFlow/prod-eng diff --git a/.github/REPOSITORY_SETUP.md b/.github/REPOSITORY_SETUP.md index efc4c5e..0354f7a 100644 --- a/.github/REPOSITORY_SETUP.md +++ b/.github/REPOSITORY_SETUP.md @@ -6,18 +6,20 @@ owner must configure the GitHub repository after the initial `main` push. 1. Keep `FlutterFlow/campus-extensions` public and set `main` as the default branch. 2. Ensure the existing `FlutterFlow/prod-eng` team referenced by CODEOWNERS - retains write access. Replace it with a narrower organization-owned reviewer - team later only as one atomic CODEOWNERS + repository-access change. + retains write access. CODEOWNERS is advisory during the registry preview; + replace it with a narrower organization-owned reviewer team later only as + one atomic CODEOWNERS + repository-access change. 3. Protect `main` with: - pull requests required; - - at least one approving review, **Require review from Code Owners**, and - stale approvals dismissed; - - approval required for the latest reviewable push; + - zero required approvals during the registry preview, with code-owner and + latest-push approval requirements disabled so maintainers can self-merge; - required check `Canonical catalog, packages, and tests`; - branches required to be up to date; - conversation resolution and linear history required; - enforcement for administrators; - force pushes and branch deletion disabled. + Re-enable at least one independent code-owner approval after the preview, + once real extension usage has informed the permanent review policy. 4. Create a GitHub App dedicated to generated publication PRs. Grant it only repository **Contents: read/write** and **Pull requests: read/write**. Store its App ID and private key as `CAMPUS_PUBLICATION_APP_ID` and diff --git a/.github/workflows/prepare-publication-pr.yml b/.github/workflows/prepare-publication-pr.yml index 9409969..15b35f4 100644 --- a/.github/workflows/prepare-publication-pr.yml +++ b/.github/workflows/prepare-publication-pr.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: extension_id: - description: Reverse-DNS extension ID already reviewed on main + description: Reverse-DNS extension ID already validated and merged on main required: true type: string description: @@ -44,7 +44,7 @@ concurrency: jobs: validate-source: - name: Validate reviewed source without publication credentials + name: Validate merged source without publication credentials runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: @@ -78,7 +78,7 @@ jobs: with: node-version: "22" - - name: Validate reviewed source and current catalog + - name: Validate merged source and current catalog run: | python3 -m unittest discover -s tool/tests -v node --test "extensions/$EXTENSION_ID"/test/*.test.mjs @@ -88,7 +88,7 @@ jobs: run: python3 tool/native_release_gate.py --extension "$EXTENSION_ID" prepare: - name: Package reviewed source and open generated PR + name: Package merged source and open generated PR needs: validate-source runs-on: macos-15 timeout-minutes: 20 @@ -183,7 +183,7 @@ jobs: run: | body="$(mktemp)" printf '%s\n' \ - 'Generated from reviewed source already merged to `main`.' \ + 'Generated from validated source already merged to `main`.' \ '' \ '- The artifact is canonical and content-addressed.' \ '- The catalog and archived manifest authority projection match.' \ diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c86eb2d..3f693ec 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - - name: Check out reviewed bytes + - name: Check out registry bytes uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be2dcd4..1dc8a52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,13 +20,16 @@ product quality and the authority an extension asks users to grant. real user accounts or depend on host credentials. 6. Run the repository tests and validator. -Every source change requires a pull request, at least one approving review from -a code owner, and all required checks. Force-pushes to the default branch and -direct catalog/artifact edits are prohibited by branch protection. +During the registry preview, every source change requires a pull request and +all required checks, but an independent or code-owner approval is optional. +Maintainers may merge their own green pull requests. We expect to require +independent review once real-world usage gives us enough evidence to tune the +policy. Force-pushes to the default branch and direct catalog/artifact edits +remain prohibited by branch protection. -## Review checklist +## Maintainer checklist -Reviewers check: +Before merge, check: - the package identity and version are stable and intentional; - code behavior matches the README and visible UI; diff --git a/README.md b/README.md index 4c44fed..95aed60 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ The official extension registry for FlutterFlow Campus. -Browse and install reviewed extensions from **Campus → Settings → Extensions**. +Browse and install curated extensions from **Campus → Settings → Extensions**. ## Available extensions @@ -25,16 +25,16 @@ Extensions live under `extensions//`. 4. Open a pull request. Do not edit `campus.registry.json` or files under `artifacts/` by hand. -Maintainers publish reviewed extensions through CI. +Maintainers publish validated extensions through CI. -See [CONTRIBUTING.md](CONTRIBUTING.md) for the package structure and review +See [CONTRIBUTING.md](CONTRIBUTING.md) for the package structure and submission process. ## Trust and security -Extension source and published artifacts are reviewed and validated before they -reach Campus. Campus also shows an extension's requested access before enabling -it. Extensions can run with user-granted permissions, so install only extensions +Extension source and published artifacts are validated before they reach +Campus. Campus also shows an extension's requested access before enabling it. +Extensions can run with user-granted permissions, so install only extensions you trust. Please report security issues as described in [SECURITY.md](SECURITY.md). diff --git a/SECURITY.md b/SECURITY.md index e25ba98..08444d7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,7 +11,7 @@ and withdraw affected releases when necessary. ## Trust boundary -Git history and SHA-256 content addressing establish which reviewed bytes a +Git history and SHA-256 content addressing establish which accepted bytes a client received. They do not make extension code harmless. Once a user grants a raw process, native helper, full filesystem resource, unrestricted HTTP resource, or raw secret, the extension can exercise that authority without a @@ -20,7 +20,7 @@ the host cannot honestly enforce an exact filesystem or network-origin boundary inside arbitrary child code. Registry tooling must never execute package code during validation or client -installation. Publication may run reviewed build steps only after merge in a +installation. Publication may run maintainer-approved build steps only after merge in a trusted job, with signing secrets unavailable to pull-request jobs. ## Response options diff --git a/extensions/io.flutterflow.campus.usage-limits/README.md b/extensions/io.flutterflow.campus.usage-limits/README.md index b28c1ea..c2dac50 100644 --- a/extensions/io.flutterflow.campus.usage-limits/README.md +++ b/extensions/io.flutterflow.campus.usage-limits/README.md @@ -130,4 +130,4 @@ source pull request. Validation does not grant the process resource, enable the package, install a Claude status-line adapter, or execute extension code. Publication is a -separate reviewed catalog/artifact change. +separate generated catalog/artifact change. diff --git a/extensions/io.flutterflow.campus.x-draft-card/CHANGELOG.md b/extensions/io.flutterflow.campus.x-draft-card/CHANGELOG.md new file mode 100644 index 0000000..636a203 --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.5.0 + +- Initial official-registry release of the room-shared X Draft Card. diff --git a/extensions/io.flutterflow.campus.x-draft-card/README.md b/extensions/io.flutterflow.campus.x-draft-card/README.md new file mode 100644 index 0000000..0bd5096 --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/README.md @@ -0,0 +1,228 @@ +# X Draft Card extension + +This package is deliberately one thing: a tweet-shaped, room-shared notepad. +It opens one `twitter.card` scene with no extension toolbar, navigation, +library, preview mode, publishing controls, or X API integration. The trusted +Campus launcher presents it as a canvas object, so the card itself is the whole +visible tile. + +The extension asks for only: + +```json +["input.image.drop", "state.shared.write", "surface.scene.write"] +``` + +Its schema-v3 manifest has one bounded shared document, one host-owned shared +text binding, and only two guest semantic actions: owner-only `addPostImages` +and interactor-eligible `removePostImage`. There is no text-update action, +filesystem slot, network capability, process, OAuth credential, clipboard +write, background task, or external effect. The drop capability is an explicit +request for Campus to import an image; it does not grant the extension a path, +file handle, or raw source bytes. + +## Editing and synchronization + +The body uses the host-native scene `Input` in `plain` appearance and binds it +to `sharedTexts.postText`. Campus owns the bounded CRDT document and updates the +local native editor immediately; neither the coordinator nor renderer executes +for each keystroke. Accepted incremental updates converge through the normal +owner-authoritative state lane, so Alice and every allowed editor work on the +same draft rather than independently submitting snapshots. + +Caret, selection, focus, and the active IME composing range stay local. Campus +does not broadcast an incomplete composition or make extension authors handle +platform composition events. When composition completes, the host merges the +local edit with any accepted remote CRDT updates and resumes bounded +synchronization. Like X timeline copy, the editor is content-height: it starts +at one line, grows with hard and soft wrapping, and shrinks again when text is +deleted. Campus caps the native editor at 25 visible lines so even the complete +one-image anatomy stays inside the signed 900 px surface bound; only text beyond +that defensive cap scrolls internally. + +The draft bound is 280 Unicode scalar values and 2,048 JSON-encoded UTF-8 +bytes. Control characters are removed, line endings are canonicalized, and +the whole state is capped below its 64 KiB pool. The radial remaining counter +is a host-native part of the `Input`: it follows the local editor immediately +without executing extension code for every keypress. The renderer does not +repeat `maxLength` or `maxEncodedBytes`; the signed `sharedTexts.postText` +declaration is the single limit authority. + +This intentionally does not claim exact X publishing eligibility. The offline +draft uses one deterministic slot per Unicode scalar (so an astral emoji is one +slot, not two UTF-16 code units); X can apply different server-side weighting +for URLs, emoji sequences, or some scripts. Exact X policy should eventually +be a separately versioned host text metric rather than reimplemented ad hoc by +each extension. + +## Image drops + +Drop up to four static PNG/JPEG files anywhere on Alice's card. The complete +card is the local drag target; the renderer places one layout-neutral 1 × 1 +`DropZone` admission marker bound to the manifest's `post-images` +`assetInput`, but does not synthesize a callback, inspect a drag event, or +define a narrower spatial hit region. The marker is deliberately invisible at +rest. Campus renders a viewer-local full-card target only while an acceptable +native file drag is hovering, then checks the declared per-source 8 MiB ceiling +before allocating the full input, decodes it in a concurrency-bounded worker +isolate with a hard deadline, rejects animation and oversized canvases, bakes +orientation, strips metadata, and re-encodes a bounded static JPEG. Hover +state, file paths, handles, and raw bytes are never synchronized. Only accepted +opaque asset results enter shared state. + +The signed surface layout fixes width at 598 logical pixels and makes height +host-measured, bounded from 124 through 900 pixels. Campus measures the native +scene, clamps it to those bounds, and commits the resulting tile geometry as +the authoritative projection. Height follows the wrapped draft plus optional +media instead of a frozen composer allocation. For example, a short no-media +draft resolves to the signed 598 × 124 minimum, a four-line draft to 598 × 174, +and the screenshot-shaped three-line draft plus current 16:9 media well to +598 × 457.375. The renderer never publishes a pixel size or an arbitrary +layout-mode name. The admission marker remains in the scene until four images +have been accepted, so subsequent drops retain the same whole-card hover target +without adding idle UI or affecting measurement. + +The coordinator and shared state see only random `host_image_ref_v1_…` tokens +plus dimensions. Pure renderers receive no bytes; Campus substitutes a token +only when validating an exact `Image.source` in the returned scene. Peers then +receive the already validated retained scene checkpoint, not access to Alice's +filesystem. The schema-v3 action definition gives `addPostImages` audience +`owner`, while `removePostImage` has audience `interactors`; the host derives +the corresponding admission policy rather than trusting a second +`peerActions` list. + +The current prototype registry is session-local and bounded to 32 live sources +and 2 MiB of retained canonical bytes. A newly admitted batch stays pinned +while its action is queued and rendered; after settlement Campus traces exact +opaque tokens through canonical state and releases rejected, removed, and +otherwise unreferenced sources. Tokens are still not durable across restart. +Production needs a content-addressed asset store plus the same reference-fenced +lifecycle across persistence; token reuse or blind deletion would create +stale-reference and substitution bugs. + +## Identity settings + +The signed manifest declares three host-rendered settings: + +- `displayName`: a string available as an extension-wide default and an + optional per-tile override; when unset it follows the host-projected Campus + profile name; + +- `handle`: a string available as an extension-wide default and an optional + per-tile override; +- `avatar`: a global-only `profileAvatar` binding that follows the current + authoritative executor's Campus profile picture. + +Extensions declare schemas; they do not draw arbitrary Settings UI. Campus +owns validation, persistence, inheritance (`tile override -> device-profile +value -> manifest default`), and profile-avatar resolution. The renderer +accepts only a finite resolved identity projection with an opaque protocol +image token through `inputs.settings`. Campus resolves it against a +surface-local map, retains the bounded thumbnail, and substitutes it into an +`image.source` only after the untrusted renderer returns. Extension code never +receives the image bytes, profile's local file path, remote URL, account token, +or permission to fetch an image. Until that host projection is present, it +safely renders `Campus`, `@campus`, and an initials avatar. +The avatar remains distinct from post-image drops: it is profile-derived host +material, not extension state or a per-tile upload. + +## Fidelity boundary + +The card keeps the frozen `x-web-timeline@2026-07-23` anatomy, dark palette, +and captured inactive timeline icon paths from the earlier fidelity +experiment: 598 px width, 16/12 px padding, a 40 px avatar, 15/20 text metrics, +and 36 px engagement slots. The text block is intrinsic, matching the current +timeline renderer's pre-wrapped content flow instead of reserving four lines. +The editable field uses those same text tokens rather than a visually separate +Campus form field. The standalone canvas object deliberately ends at its +content edge and omits X's timeline-feed bottom separator: there is no +following feed item to separate. + +Attached media still uses the prototype's 518 × 291.375 px 16:9 grid. Current +X single-photo cards preserve the admitted source ratio and apply a portrait +height cap, so arbitrary one-photo aspect-ratio fidelity remains explicit +follow-up work rather than something this profile claims to reproduce. + +This is an internal drafting skin, not an embedded or fabricated live post. +It uses `systemSans`; exact Chromium/X glyph rasterization still requires a +legally usable content-addressed reference font and a native differential +capture. Public distribution of copied visual assets also needs brand/IP +review. + +## Run and test + +Two-peer Playground scenario: + +```sh +scripts/playground scenario twitter_draft_studio_lab +``` + +Once Alice's card is ready, the two settings scopes can be checked without +adding any extension-owned chrome: + +1. Open **Settings → Extensions**, then find + **X Draft Card preferences · io.flutterflow.campus.x-draft-card** and edit + **Name** and **Handle**. The group appears after the accepted manifest + activates. +2. Change the picture under **Settings → Account → Profile photo**. The + extension's Avatar row is a non-editable, host-owned **Campus profile** + reference; neither the renderer nor tile state receives a path or URL. +3. Right-click Alice's frameless card and choose **Tile Settings** to set or + reset its Name or Handle override. A `canvasObject` intentionally has no + hover gear. +4. Confirm Alice and Bob render the same identity. Resolution order is + `tile override -> device-profile value -> manifest default` for Handle and + Name. An unset Name falls through to the projected Campus profile name; + Avatar always follows the current Campus profile. +5. Drag one to four PNG/JPEG files onto Alice's card. Confirm the card grows, + both peers show the same X-style media grid, and either peer with Interact + permission can use an image's close button. Bob cannot originate a file + import because only Alice owns the executor and source filesystem. +6. Press **Cmd+K** or right-click blank canvas and choose **X Draft Card** to + create another card. In this Playground scenario the entry is leased by an + already running, exact-hash X Draft runtime; the normal debug app can also + provide the first launcher from its installed-extension catalog. + +Automated multiplayer convergence and access-revocation test: + +```sh +scripts/playground e2e twitter_draft_studio_multiplayer +``` + +Focused package tests: + +```sh +node --test \ + examples/campus_extensions/twitter_draft_studio/test/twitter_draft_studio.test.mjs + +"${TSC:?set TSC to a pinned local TypeScript compiler}" \ + --project examples/campus_extensions/twitter_draft_studio/tsconfig.json \ + --noEmit +``` + +This checkout intentionally does not install TypeScript. Point `TSC` at the +compiler binary from the authoring workspace's pinned dependency; do not let +`npx` fetch an unpinned compiler. + +The checked-in example root is an authoring tree: its README, tests, TypeScript, +TSX, and `tsconfig.json` are intentionally outside the production-v1 package +allowlist. Compile and copy only the manifest, compiled JavaScript/source maps, +generated bindings, and allowed assets into a clean staging package. After +changing `campus.extension.json`, regenerate and validate that staging package: + +```sh +./scripts/campus extension generate /path/to/twitter-draft-staging +./scripts/campus extension validate /path/to/twitter-draft-staging +``` + +`src/campus.generated.mjs` and `src/campus.generated.d.mts` contain literal IDs +for actions, asset inputs, and shared texts. They grant no authority; the +signed manifest remains the source used by the host. Layout constraints stay +declarative manifest policy rather than guest-selectable bindings. + +Runtime exercise: + +```sh +# The former Node runner was deleted; QuickJS-in-Wasm is the one engine. +# Exercise this example through its Playground lab: +scripts/playground scenario twitter_draft_studio_lab +``` diff --git a/extensions/io.flutterflow.campus.x-draft-card/campus.extension.json b/extensions/io.flutterflow.campus.x-draft-card/campus.extension.json new file mode 100644 index 0000000..1a51756 --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/campus.extension.json @@ -0,0 +1,125 @@ +{ + "schemaVersion": 3, + "id": "io.flutterflow.campus.x-draft-card", + "name": "X Draft Card", + "version": "0.5.0", + "entry": "src/main.js", + "capabilities": { + "maximum": ["input.image.drop", "state.shared.write", "surface.scene.write"], + "required": ["input.image.drop", "state.shared.write", "surface.scene.write"] + }, + "resources": {}, + "tasks": {}, + "statePools": { + "draftState": { + "scope": "shared.document", + "maxBytes": 65536 + } + }, + "sharedTexts": { + "postText": { + "statePool": "draftState", + "stateField": "text", + "audience": "interactors", + "maxScalars": 280, + "maxEncodedBytes": 2048 + } + }, + "surfacePools": { + "card": { + "protocol": "scene.v1", + "statePool": "draftState", + "renderer": "src/render_card.js#render", + "layout": { + "width": {"strategy": "fill"}, + "height": {"strategy": "content"}, + "minSize": {"width": 430, "height": 124}, + "maxSize": {"width": 598, "height": 900} + }, + "maxNodes": 160, + "maxDepth": 16, + "maxBytes": 524288 + } + }, + "contributes": { + "launchers": [ + { + "id": "twitter-draft-card", + "title": "X Draft Card", + "subtitle": "A room-shared, tweet-shaped notepad with no X integration", + "surfaces": [ + { + "id": "twitter.card", + "surfacePool": "card", + "title": "X Draft Card", + "role": "primary", + "frameMode": "canvasObject", + "column": 0, + "row": 0, + "columnSpan": 1, + "rowSpan": 1, + "defaultWidth": 598, + "defaultHeight": 124 + } + ] + } + ], + "assetInputs": [ + { + "id": "post-images", + "surfacePool": "card", + "kind": "image", + "accept": ["image/jpeg", "image/png"], + "maxItems": 4, + "maxSourceBytes": 8388608, + "action": "addPostImages", + "label": "Drop images to attach" + } + ], + "settings": [ + { + "id": "displayName", + "type": "string", + "label": "Name", + "description": "The display name shown on X draft cards. When unset, the Campus profile name is used.", + "allowedScopes": ["global", "tile"], + "minLength": 1, + "maxLength": 50, + "requireTrimmed": true, + "requireNonBlank": true, + "placeholder": "Campus profile name" + }, + { + "id": "handle", + "type": "string", + "label": "Handle", + "description": "The handle shown on X draft cards.", + "allowedScopes": ["global", "tile"], + "default": "@campus", + "minLength": 1, + "maxLength": 32, + "requireTrimmed": true, + "requireNonBlank": true, + "placeholder": "@username" + }, + { + "id": "avatar", + "type": "profileAvatar", + "label": "Avatar", + "description": "The avatar shown on X draft cards.", + "allowedScopes": ["global"], + "default": "campusProfile" + } + ] + }, + "actions": { + "addPostImages": { + "effect": "sharedDocumentMutation", + "audience": "owner" + }, + "removePostImage": { + "effect": "sharedDocumentMutation", + "audience": "interactors" + } + } +} diff --git a/extensions/io.flutterflow.campus.x-draft-card/src/campus.generated.d.mts b/extensions/io.flutterflow.campus.x-draft-card/src/campus.generated.d.mts new file mode 100644 index 0000000..8532941 --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/src/campus.generated.d.mts @@ -0,0 +1,33 @@ +// Generated by `campus extension generate`; do not edit. +// Literal types only; this declaration grants no host authority. + +export declare const actions: Readonly<{ + readonly "addPostImages": "addPostImages"; + readonly "removePostImage": "removePostImage"; +}>; +export declare const statePools: Readonly<{ + readonly "draftState": "draftState"; +}>; +export declare const surfacePools: Readonly<{ + readonly "card": "card"; +}>; +export declare const sharedTexts: Readonly<{ + readonly "postText": "postText"; +}>; +export declare const assetInputs: Readonly<{ + readonly "post-images": "post-images"; +}>; +export declare const settings: Readonly<{ + readonly "avatar": "avatar"; + readonly "displayName": "displayName"; + readonly "handle": "handle"; +}>; +export declare const commands: Readonly<{ +}>; +export declare const launchers: Readonly<{ + readonly "twitter-draft-card": "twitter-draft-card"; +}>; +export declare const layoutModes: Readonly<{ + readonly "card": Readonly<{ + }>; +}>; diff --git a/extensions/io.flutterflow.campus.x-draft-card/src/campus.generated.mjs b/extensions/io.flutterflow.campus.x-draft-card/src/campus.generated.mjs new file mode 100644 index 0000000..9e10e1e --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/src/campus.generated.mjs @@ -0,0 +1,33 @@ +// Generated by `campus extension generate`; do not edit. +// This file contains inert IDs only and grants no host authority. + +export const actions = Object.freeze({ + "addPostImages": "addPostImages", + "removePostImage": "removePostImage", +}); +export const statePools = Object.freeze({ + "draftState": "draftState", +}); +export const surfacePools = Object.freeze({ + "card": "card", +}); +export const sharedTexts = Object.freeze({ + "postText": "postText", +}); +export const assetInputs = Object.freeze({ + "post-images": "post-images", +}); +export const settings = Object.freeze({ + "avatar": "avatar", + "displayName": "displayName", + "handle": "handle", +}); +export const commands = Object.freeze({ +}); +export const launchers = Object.freeze({ + "twitter-draft-card": "twitter-draft-card", +}); +export const layoutModes = Object.freeze({ + "card": Object.freeze({ + }), +}); diff --git a/extensions/io.flutterflow.campus.x-draft-card/src/main.js b/extensions/io.flutterflow.campus.x-draft-card/src/main.js new file mode 100644 index 0000000..a5d4b62 --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/src/main.js @@ -0,0 +1,57 @@ +import { + applyDraftAction, + actionIds, + createInitialState, + normalizeState, +} from './model.js'; + +export async function activate(ctx) { + const model = await ctx.state.open('draftState', 'main'); + + for (const actionId of actionIds) { + await ctx.actions.handle( + actionId, + async ({payload, actorRef}, actionCtx) => { + const next = applyDraftAction( + model.snapshot, + actionId, + payload, + actorRef, + ); + if (next === null) return null; + return actionCtx.state.patch(model, next); + }, + ); + } + + const canonical = isInitialized(model.snapshot) + ? normalizeState(model.snapshot) + : createInitialState(); + if (!isCanonicalSnapshot(model.snapshot, canonical)) { + await ctx.state.patch(model, canonical); + } + + await ctx.ui.openSurface({ + pool: 'card', + id: 'twitter.card', + inputs: {model}, + }); + + return {surfaces: ['twitter.card']}; +} + +function isInitialized(value) { + return value !== null && + typeof value === 'object' && + !Array.isArray(value) && + value.schemaVersion === 1; +} + +function isCanonicalSnapshot(value, canonical) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const known = {}; + for (const key of Object.keys(canonical)) known[key] = value[key]; + return JSON.stringify(known) === JSON.stringify(canonical); +} diff --git a/extensions/io.flutterflow.campus.x-draft-card/src/model.js b/extensions/io.flutterflow.campus.x-draft-card/src/model.js new file mode 100644 index 0000000..874b1b6 --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/src/model.js @@ -0,0 +1,181 @@ +import {actions} from './campus.generated.mjs'; + +export const draftLimits = Object.freeze({ + maxPostScalars: 280, + maxPostEncodedBytes: 2048, + maxPostImages: 4, + maxStateEncodedBytes: 65536, +}); + +export const actionIds = Object.freeze([ + actions.addPostImages, + actions.removePostImage, +]); + +export function createInitialState() { + return { + schemaVersion: 1, + text: '', + images: [], + actionSeq: 0, + lastActorRef: 'extension', + }; +} + +export function normalizeState(raw) { + const source = asObject(raw); + return { + schemaVersion: 1, + text: plainDraftText(source.text), + images: plainPostImages(source.images), + actionSeq: safeSequence(source.actionSeq), + lastActorRef: plainActorRef(source.lastActorRef, 'extension'), + }; +} + +export function applyDraftAction(current, actionId, payload, actorRef) { + const input = asObject(payload); + const state = normalizeState(current); + let nextState; + if (actionId === actions.addPostImages) { + const additions = plainPostImages(input.images); + if (additions.length === 0) return null; + const existingTokens = new Set( + state.images.map((item) => item.sourceToken), + ); + const images = [...state.images]; + for (const item of additions) { + if (images.length >= draftLimits.maxPostImages) break; + if (existingTokens.has(item.sourceToken)) continue; + existingTokens.add(item.sourceToken); + images.push(item); + } + if (images.length === state.images.length) return null; + nextState = {...state, images}; + } else if (actionId === actions.removePostImage) { + const sourceToken = plainPostImageToken(input.sourceToken); + if (sourceToken === null) return null; + const images = state.images.filter( + (item) => item.sourceToken !== sourceToken, + ); + if (images.length === state.images.length) return null; + nextState = {...state, images}; + } else { + return null; + } + + const candidate = { + ...nextState, + actionSeq: Math.min(Number.MAX_SAFE_INTEGER, state.actionSeq + 1), + lastActorRef: plainActorRef(actorRef, 'unknown actor'), + }; + return encodedJsonBytes(candidate) <= draftLimits.maxStateEncodedBytes + ? candidate + : null; +} + +function plainPostImages(value) { + if (!Array.isArray(value)) return []; + const images = []; + const seen = new Set(); + for (const candidate of value) { + if (images.length >= draftLimits.maxPostImages) break; + const source = asObject(candidate); + const sourceToken = plainPostImageToken(source.sourceToken); + const width = plainPostImageDimension(source.width); + const height = plainPostImageDimension(source.height); + if (sourceToken === null || width === null || height === null) continue; + if (seen.has(sourceToken)) continue; + seen.add(sourceToken); + images.push({sourceToken, width, height}); + } + return images; +} + +function plainPostImageToken(value) { + return typeof value === 'string' && + /^host_image_ref_v1_[A-Za-z0-9_-]{24}$/u.test(value) + ? value + : null; +} + +function plainPostImageDimension(value) { + return Number.isInteger(value) && value >= 1 && value <= 1200 + ? value + : null; +} + +function plainDraftText(value) { + if (typeof value !== 'string') return ''; + const scalarBounded = truncateScalars( + value + .replace(/\r\n?/gu, '\n') + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, ' '), + draftLimits.maxPostScalars, + ); + return truncateEncodedJsonString( + scalarBounded, + draftLimits.maxPostEncodedBytes, + ); +} + +function plainActorRef(value, fallback) { + if (typeof value !== 'string') return fallback; + const text = truncateScalars( + value.replace(/[\u0000-\u001f\u007f]/gu, ' ').trim(), + 64, + ); + return text || fallback; +} + +function safeSequence(value) { + return Number.isSafeInteger(value) && value >= 0 + ? value + : 0; +} + +export function truncateScalars(value, maximum) { + if (typeof value !== 'string' || maximum <= 0) return ''; + const scalars = Array.from(value); + return scalars.length <= maximum ? value : scalars.slice(0, maximum).join(''); +} + +export function truncateEncodedJsonString(value, maximumBytes) { + if (typeof value !== 'string' || maximumBytes < 2) return ''; + if (encodedJsonBytes(value) <= maximumBytes) return value; + const scalars = Array.from(value); + let lower = 0; + let upper = scalars.length; + while (lower < upper) { + const midpoint = Math.floor((lower + upper + 1) / 2); + if (encodedJsonBytes(scalars.slice(0, midpoint).join('')) <= maximumBytes) { + lower = midpoint; + } else { + upper = midpoint - 1; + } + } + return scalars.slice(0, lower).join(''); +} + +export function encodedJsonBytes(value) { + const encoded = JSON.stringify(value); + if (typeof encoded !== 'string') return 0; + let bytes = 0; + for (const scalar of encoded) { + const codePoint = scalar.codePointAt(0); + bytes += codePoint <= 0x7f + ? 1 + : codePoint <= 0x7ff + ? 2 + : codePoint <= 0xffff + ? 3 + : 4; + } + return bytes; +} + +function asObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value + : {}; +} diff --git a/extensions/io.flutterflow.campus.x-draft-card/src/render_card.js b/extensions/io.flutterflow.campus.x-draft-card/src/render_card.js new file mode 100644 index 0000000..65f0cd0 --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/src/render_card.js @@ -0,0 +1,93 @@ +import { jsx as _jsx, jsxs as _jsxs } from "campus:ui/jsx-runtime"; +import { Box, Canvas, Clip, Column, DropZone, Image, Input, Positioned, Row, Stack, Text, Transform, action, defineRenderer, } from 'campus:ui'; +import { resolveTwitterCardWidth, resolveTwitterDevice, resolveTwitterIdentity, twitterModel, twitterTheme, } from './render_shared.js'; +import { twitterDraftEditorProfile, xTimelineContentWidthForCard, xTimelineHandleMaxWidth, xTimelineMediaHeightForContent, xTimelineNameMaxWidth, xWebTimelineProfile, } from './x_fidelity_profile.js'; +import { xIconCommands, } from './x_icon_paths.js'; +import { assetInputs, sharedTexts, } from './campus.generated.mjs'; +export const render = defineRenderer(TwitterDraftCard); +function TwitterDraftCard({ view }) { + const model = twitterModel(view); + const identity = resolveTwitterIdentity(view); + const theme = twitterTheme; + // Campus hands the surface a width it declared it can render (fill, bounded + // by the manifest's 430..598); the card picks the matching x.com profile. + const device = resolveTwitterDevice(view); + // Structure comes from the profile, extent from the tile: the card fills the + // width Campus gave it instead of snapping back to the nearest native column. + const cardWidth = resolveTwitterCardWidth(view); + const contentWidth = xTimelineContentWidthForCard(cardWidth); + return (_jsx(Box, { id: "twitter-card", width: cardWidth, background: theme.background, semanticsLabel: `X-style draft card by ${identity.displayName}. Room-shared editable text.`, children: _jsx(Column, { id: "twitter-card:layout", crossAlign: "stretch", children: _jsx(Box, { id: "twitter-card:skin", padding: [ + xWebTimelineProfile.card.paddingVertical, + xWebTimelineProfile.card.paddingHorizontal, + ], background: theme.background, children: _jsxs(Row, { id: "twitter-card:row", gap: xWebTimelineProfile.card.avatarContentGap, crossAlign: "start", children: [_jsx(Avatar, { id: "twitter-card:avatar", identity: identity }), _jsxs(Column, { id: "twitter-card:content", gap: xWebTimelineProfile.card.contentGap, flex: 1, crossAlign: "stretch", children: [_jsxs(Column, { id: "twitter-card:copy", gap: xWebTimelineProfile.card.copyGap, crossAlign: "stretch", children: [_jsx(AuthorHeader, { id: "twitter-card:author", identity: identity, device: device }), _jsxs(Stack, { id: "twitter-card:composer", width: contentWidth, children: [_jsx(Input, { id: "twitter-card:body", sharedText: sharedTexts.postText, placeholder: "What is happening?!", multiline: true, minLines: twitterDraftEditorProfile.minVisibleLines, maxLines: twitterDraftEditorProfile.maxVisibleLines, counter: "radialRemaining", counterWarningThreshold: 20, counterColor: theme.blue, counterTrackColor: theme.separator, counterWarningColor: "#FFD400", counterDangerColor: "#F4212E", appearance: "plain", color: theme.text, placeholderColor: theme.secondary, cursorColor: theme.blue, font: xWebTimelineProfile.text.font, fontSize: xWebTimelineProfile.text.bodySize, fontWeight: xWebTimelineProfile.text.bodyWeight, lineHeight: xWebTimelineProfile.text.bodyLineHeight, semanticsLabel: "Tweet draft text. Changes synchronize automatically while you type." }), model.images.length < 4 + ? _jsx(Positioned, { id: "twitter-card:image-drop-hint-position", right: 0, top: 0, children: _jsx(DropZone, { id: "twitter-card:image-drop-hint", assetInput: assetInputs['post-images'], width: 1, height: 1 }) }) + : null] })] }), model.images.length > 0 + ? _jsx(PostMedia, { id: "twitter-card:media", images: model.images, contentWidth: contentWidth }) + : null, _jsx(EngagementRow, { id: "twitter-card:engagement", color: theme.secondary })] })] }) }) }) })); +} +function PostMedia({ id, images, contentWidth, }) { + const mediaWidth = contentWidth; + const mediaHeight = xTimelineMediaHeightForContent(contentWidth); + return (_jsx(Box, { id: id, width: mediaWidth, height: mediaHeight, radius: xWebTimelineProfile.media.radius, borderColor: twitterTheme.separator, borderWidth: xWebTimelineProfile.media.borderWidth, children: _jsx(Clip, { id: id + ':clip', width: mediaWidth, height: mediaHeight, radius: xWebTimelineProfile.media.radius, children: _jsx(Stack, { id: id + ':grid', width: mediaWidth, height: mediaHeight, children: images.map((item, index) => { + const rect = postMediaRect(images.length, index, mediaWidth, mediaHeight); + return (_jsx(Positioned, { id: `${id}:slot:${index}`, left: rect.left, top: rect.top, width: rect.width, height: rect.height, children: _jsx(PostMediaCell, { id: `${id}:image:${index}`, image: item, width: rect.width, height: rect.height }) })); + }) }) }) })); +} +function PostMediaCell({ id, image: postImage, width, height, }) { + return (_jsxs(Stack, { id: id, width: width, height: height, children: [_jsx(Image, { id: id + ':content', source: postImage.sourceToken, width: width, height: height, fit: "cover", decodeWidth: Math.min(postImage.width, Math.ceil(width * 2)), decodeHeight: Math.min(postImage.height, Math.ceil(height * 2)), semanticsLabel: "Attached draft image" }), _jsx(Positioned, { id: id + ':remove-position', top: 8, right: 8, children: _jsx(Canvas, { id: id + ':remove', width: 24, height: 24, viewBox: [0, 0, 24, 24], commands: [ + { op: 'circle', cx: 12, cy: 12, radius: 12, fill: '#B3000000' }, + { op: 'line', x1: 8, y1: 8, x2: 16, y2: 16, stroke: '#FFFFFF', strokeWidth: 2, strokeCap: 'round' }, + { op: 'line', x1: 16, y1: 8, x2: 8, y2: 16, stroke: '#FFFFFF', strokeWidth: 2, strokeCap: 'round' }, + ], semanticsLabel: "Remove attached image", onTap: action('removePostImage', { sourceToken: postImage.sourceToken }) }) })] })); +} +function postMediaRect(count, index, width, height) { + const seam = xWebTimelineProfile.media.seam; + const halfWidth = (width - seam) / 2; + const halfHeight = (height - seam) / 2; + if (count === 1) + return { left: 0, top: 0, width, height }; + if (count === 2) { + return { + left: index === 0 ? 0 : halfWidth + seam, + top: 0, + width: halfWidth, + height, + }; + } + if (count === 3 && index === 0) { + return { left: 0, top: 0, width: halfWidth, height }; + } + const gridIndex = count === 3 ? index - 1 : index; + const column = count === 3 ? 1 : gridIndex % 2; + const row = count === 3 ? gridIndex : Math.floor(gridIndex / 2); + return { + left: column * (halfWidth + seam), + top: row * (halfHeight + seam), + width: halfWidth, + height: halfHeight, + }; +} +function AuthorHeader({ id, identity, device, }) { + const theme = twitterTheme; + return (_jsxs(Row, { id: id, height: xWebTimelineProfile.text.bodyLineHeight, gap: 0, crossAlign: "center", expand: true, children: [_jsx(Text, { id: id + ':name', color: theme.text, font: xWebTimelineProfile.text.font, fontSize: xWebTimelineProfile.text.bodySize, fontWeight: xWebTimelineProfile.text.nameWeight, lineHeight: xWebTimelineProfile.text.bodyLineHeight, maxLines: 1, maxWidth: xTimelineNameMaxWidth(device), overflow: "ellipsis", children: identity.displayName }), _jsx(Box, { id: id + ':name-gap', width: xWebTimelineProfile.header.nameBadgeGap }), _jsx(Text, { id: id + ':handle', color: theme.secondary, font: xWebTimelineProfile.text.font, fontSize: xWebTimelineProfile.text.bodySize, fontWeight: xWebTimelineProfile.text.bodyWeight, lineHeight: xWebTimelineProfile.text.bodyLineHeight, maxLines: 1, maxWidth: xTimelineHandleMaxWidth(device), overflow: "ellipsis", children: identity.handle }), _jsx(Box, { id: id + ':timestamp-leading-gap', width: xWebTimelineProfile.header.timestampInlinePadding }), _jsx(Text, { id: id + ':timestamp-separator', color: theme.secondary, font: xWebTimelineProfile.text.font, fontSize: xWebTimelineProfile.text.bodySize, fontWeight: xWebTimelineProfile.text.bodyWeight, lineHeight: xWebTimelineProfile.text.bodyLineHeight, children: "\u00B7" }), _jsx(Box, { id: id + ':timestamp-trailing-gap', width: xWebTimelineProfile.header.timestampInlinePadding }), _jsx(Text, { id: id + ':timestamp', color: theme.secondary, font: xWebTimelineProfile.text.font, fontSize: xWebTimelineProfile.text.bodySize, fontWeight: xWebTimelineProfile.text.bodyWeight, lineHeight: xWebTimelineProfile.text.bodyLineHeight, children: "now" }), _jsx(Box, { id: id + ':spacer', flex: 1 }), _jsx(Box, { id: id + ':more-target', width: xWebTimelineProfile.header.moreTargetSize, height: xWebTimelineProfile.text.bodyLineHeight, align: "center", children: _jsx(Canvas, { id: id + ':more', width: xWebTimelineProfile.header.moreIconSize, height: xWebTimelineProfile.header.moreIconSize, viewBox: [ + 0, + 0, + xWebTimelineProfile.header.moreViewBox, + xWebTimelineProfile.header.moreViewBox, + ], commands: xIconCommands('more', theme.secondary), semanticsLabel: "More menu decoration" }) })] })); +} +function Avatar({ id, identity, }) { + const size = xWebTimelineProfile.card.avatarSize; + if (identity.avatarImageSourceToken !== null) { + return (_jsx(Clip, { id: id, width: size, height: size, radius: size / 2, children: _jsx(Image, { id: id + ':image', source: identity.avatarImageSourceToken, width: size, height: size, fit: "cover", decodeWidth: 80, decodeHeight: 80, semanticsLabel: `${identity.displayName}'s Campus profile picture` }) })); + } + return (_jsx(Box, { id: id, width: size, height: size, radius: size / 2, background: twitterTheme.blue, align: "center", semanticsLabel: `${identity.displayName}'s avatar`, children: _jsx(Text, { id: id + ':initials', color: "#FFFFFF", font: "systemSans", fontSize: 14, fontWeight: 700, lineHeight: 18, children: identity.initials }) })); +} +function EngagementRow({ id, color, }) { + return (_jsx(Transform, { id: id + ':offset', translateX: xWebTimelineProfile.engagement.leadingOffset, children: _jsxs(Row, { id: id, height: xWebTimelineProfile.engagement.targetSize, expand: true, crossAlign: "center", children: [_jsx(EngagementIcon, { id: id + ':reply', icon: "reply", label: "Reply decoration", color: color, flex: true }), _jsx(EngagementIcon, { id: id + ':repost', icon: "repost", label: "Repost decoration", color: color, flex: true }), _jsx(EngagementIcon, { id: id + ':like', icon: "like", label: "Like decoration", color: color, flex: true }), _jsx(EngagementIcon, { id: id + ':views', icon: "views", label: "Views decoration", color: color, flex: true }), _jsx(EngagementIcon, { id: id + ':bookmark', icon: "bookmark", label: "Bookmark decoration", color: color }), _jsx(EngagementIcon, { id: id + ':share', icon: "share", label: "Share decoration", color: color })] }) })); +} +function EngagementIcon({ id, icon, label, color, flex = false, }) { + return (_jsx(Box, { id: id, height: xWebTimelineProfile.engagement.targetSize, ...(flex + ? { flex: 1 } + : { width: xWebTimelineProfile.engagement.targetSize }), align: "centerLeft", semanticsLabel: label, children: _jsx(Canvas, { id: id + ':icon', width: xWebTimelineProfile.engagement.iconSize, height: xWebTimelineProfile.engagement.iconSize, viewBox: [0, 0, xWebTimelineProfile.engagement.iconViewBox, xWebTimelineProfile.engagement.iconViewBox], commands: xIconCommands(icon, color) }) })); +} diff --git a/extensions/io.flutterflow.campus.x-draft-card/src/render_card.tsx b/extensions/io.flutterflow.campus.x-draft-card/src/render_card.tsx new file mode 100644 index 0000000..3b54c03 --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/src/render_card.tsx @@ -0,0 +1,457 @@ +import { + Box, + Canvas, + Clip, + Column, + DropZone, + Image, + Input, + Positioned, + Row, + Stack, + Text, + Transform, + action, + defineRenderer, + type SceneColor, +} from 'campus:ui'; + +import { + type TwitterIdentity, + type TwitterPostImage, + type TwitterView, + resolveTwitterCardWidth, + resolveTwitterDevice, + resolveTwitterIdentity, + twitterModel, + twitterTheme, +} from './render_shared.js'; +import { + type XTimelineDevice, + twitterDraftEditorProfile, + xTimelineContentWidthForCard, + xTimelineHandleMaxWidth, + xTimelineMediaHeightForContent, + xTimelineNameMaxWidth, + xWebTimelineProfile, +} from './x_fidelity_profile.js'; +import { + type XWebTimelineIcon, + xIconCommands, +} from './x_icon_paths.js'; +import { + assetInputs, + sharedTexts, +} from './campus.generated.mjs'; + +export const render = defineRenderer(TwitterDraftCard); + +function TwitterDraftCard({view}: {readonly view: TwitterView}) { + const model = twitterModel(view); + const identity = resolveTwitterIdentity(view); + const theme = twitterTheme; + // Campus hands the surface a width it declared it can render (fill, bounded + // by the manifest's 430..598); the card picks the matching x.com profile. + const device = resolveTwitterDevice(view); + // Structure comes from the profile, extent from the tile: the card fills the + // width Campus gave it instead of snapping back to the nearest native column. + const cardWidth = resolveTwitterCardWidth(view); + const contentWidth = xTimelineContentWidthForCard(cardWidth); + return ( + + + + + + + + + + + {model.images.length < 4 + ? + + + : null} + + + {model.images.length > 0 + ? + : null} + + + + + + + ); +} + +function PostMedia({ + id, + images, + contentWidth, +}: { + readonly id: string; + readonly images: readonly TwitterPostImage[]; + readonly contentWidth: number; +}) { + const mediaWidth = contentWidth; + const mediaHeight = xTimelineMediaHeightForContent(contentWidth); + return ( + + + + {images.map((item, index) => { + const rect = postMediaRect(images.length, index, mediaWidth, mediaHeight); + return ( + + + + ); + })} + + + + ); +} + +function PostMediaCell({ + id, + image: postImage, + width, + height, +}: { + readonly id: string; + readonly image: TwitterPostImage; + readonly width: number; + readonly height: number; +}) { + return ( + + + + + + + ); +} + +function postMediaRect( + count: number, + index: number, + width: number, + height: number, +) { + const seam = xWebTimelineProfile.media.seam; + const halfWidth = (width - seam) / 2; + const halfHeight = (height - seam) / 2; + if (count === 1) return {left: 0, top: 0, width, height}; + if (count === 2) { + return { + left: index === 0 ? 0 : halfWidth + seam, + top: 0, + width: halfWidth, + height, + }; + } + if (count === 3 && index === 0) { + return {left: 0, top: 0, width: halfWidth, height}; + } + const gridIndex = count === 3 ? index - 1 : index; + const column = count === 3 ? 1 : gridIndex % 2; + const row = count === 3 ? gridIndex : Math.floor(gridIndex / 2); + return { + left: column * (halfWidth + seam), + top: row * (halfHeight + seam), + width: halfWidth, + height: halfHeight, + }; +} + +function AuthorHeader({ + id, + identity, + device, +}: { + readonly id: string; + readonly identity: TwitterIdentity; + readonly device: XTimelineDevice; +}) { + const theme = twitterTheme; + return ( + + + {identity.displayName} + + + + {identity.handle} + + + + · + + + + now + + + + + + + ); +} + +function Avatar({ + id, + identity, +}: { + readonly id: string; + readonly identity: TwitterIdentity; +}) { + const size = xWebTimelineProfile.card.avatarSize; + if (identity.avatarImageSourceToken !== null) { + return ( + + + + ); + } + return ( + + + {identity.initials} + + + ); +} + +function EngagementRow({ + id, + color, +}: { + readonly id: string; + readonly color: SceneColor; +}) { + return ( + + + + + + + + + + + ); +} + +function EngagementIcon({ + id, + icon, + label, + color, + flex = false, +}: { + readonly id: string; + readonly icon: XWebTimelineIcon; + readonly label: string; + readonly color: SceneColor; + readonly flex?: boolean; +}) { + return ( + + + + ); +} diff --git a/extensions/io.flutterflow.campus.x-draft-card/src/render_shared.js b/extensions/io.flutterflow.campus.x-draft-card/src/render_shared.js new file mode 100644 index 0000000..29cdbf6 --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/src/render_shared.js @@ -0,0 +1,213 @@ +import { xTimelineCardWidth, } from './x_fidelity_profile.js'; +export const twitterTheme = Object.freeze({ + background: '#000000', + text: '#E7E9EA', + secondary: '#71767B', + separator: '#2F3336', + blue: '#1D9BF0', +}); +/** + * The local drafting contract is intentionally simpler than X's unpublished + * current composer policy: Campus admits 280 Unicode scalar values. It never + * counts UTF-16 code units, so a supplementary-plane character is one slot. + * We do not claim X publish eligibility (notably URL and CJK weighting). + */ +export const twitterCharacterLimit = 280; +export const twitterInputMaxEncodedBytes = 2048; +const twitterStateMaxEncodedBytes = 65536; +export function twitterModel(view) { + const raw = asObject(view.inputs?.model); + return { + schemaVersion: 1, + text: plainDraftText(raw.text), + images: plainPostImages(raw.images), + actionSeq: safeSequence(raw.actionSeq), + lastActorRef: plainText(raw.lastActorRef, 'extension', 64), + }; +} +/** + * Picks the fidelity profile that fits the width Campus gave this surface. + * + * The threshold is the extension's own: Campus reports pixels and takes no + * position on where a layout should change. Below the native web column the + * card renders x.com's narrow profile rather than being squeezed; a missing or + * nonsensical width falls back to `web`, matching the fail-closed style of the + * other normalizers here. + */ +export function resolveTwitterDevice(view) { + const width = view.inputs?.surface?.width; + if (typeof width !== 'number' || !Number.isFinite(width)) + return 'web'; + return width < xTimelineCardWidth('web') ? 'mobile' : 'web'; +} +/** + * The width to actually draw the card at. + * + * The device profile chooses the STRUCTURE (which name/handle maxima, which + * anatomy); this chooses the extent. They are separate on purpose: Campus + * reports a continuous width, so snapping the card back to the nearest native + * profile would reintroduce the gutter that filling the tile is meant to + * remove. Falls back to the native web column when no width is published. + */ +export function resolveTwitterCardWidth(view) { + const width = view.inputs?.surface?.width; + if (typeof width !== 'number' || !Number.isFinite(width)) { + return xTimelineCardWidth('web'); + } + // Campus already clamps to the manifest bounds; clamp again so a hostile or + // stale value can never produce a degenerate card. + const minimum = xTimelineCardWidth('mobile'); + const maximum = xTimelineCardWidth('web'); + return Math.min(Math.max(Math.round(width), minimum), maximum); +} +export function resolveTwitterIdentity(view) { + const settings = asObject(view.inputs?.settings); + const avatar = asObject(settings.avatar); + const profileDisplayName = plainText(avatar.displayName, 'Campus', 50); + const displayName = plainDisplayName(settings.displayName, profileDisplayName); + const handle = plainHandle(settings.handle); + const avatarImageSourceToken = plainAvatarImageSourceToken(avatar.imageSourceToken); + const initials = displayName + .split(/\s+/u) + .filter(Boolean) + .slice(0, 2) + .map((part) => Array.from(part)[0]?.toUpperCase() ?? '') + .join(''); + return { + displayName, + handle, + avatarImageSourceToken, + initials: initials || 'C', + }; +} +export function postInputEncodedBudget(model) { + const stateBytes = encodedJsonBytes(model); + const currentTextBytes = encodedJsonBytes(model.text); + const available = twitterStateMaxEncodedBytes - stateBytes + currentTextBytes - 1024; + return Math.max(Math.min(currentTextBytes, twitterInputMaxEncodedBytes), Math.min(twitterInputMaxEncodedBytes, Math.max(2, available))); +} +/** Count Unicode scalar values, matching the host Input formatter exactly. */ +export function twitterCharacterCount(value) { + return Array.from(value).length; +} +function plainDraftText(value) { + if (typeof value !== 'string') + return ''; + return truncateEncodedJsonString(truncateScalars(value + .replace(/\r\n?/gu, '\n') + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, ' '), twitterCharacterLimit), twitterInputMaxEncodedBytes); +} +function plainDisplayName(value, fallback) { + if (typeof value !== 'string') + return fallback; + // Campus validates the declared bound and safe-text policy. Preserve the + // admitted setting byte-for-byte so Settings never lies about what renders. + // The replacement/bound is defense in depth for direct conformance calls. + const displayName = truncateScalars(value.replace(/[\u0000-\u001f\u007f]/gu, ' '), 50); + return displayName === '' ? fallback : displayName; +} +function plainText(value, fallback, maxLength) { + if (typeof value !== 'string') + return fallback; + const text = truncateScalars(value.replace(/[\u0000-\u001f\u007f]/gu, ' ').trim(), maxLength); + return text || fallback; +} +function plainHandle(value) { + if (typeof value !== 'string') + return '@campus'; + // Campus has already applied the signed schema's bounds and safe-text + // checks. Preserve every admitted value exactly so Settings never claims a + // value that the card silently rewrites. The defensive replacement and + // bound cover direct renderer-conformance calls outside that host boundary. + const handle = truncateScalars(value.replace(/[\u0000-\u001f\u007f]/gu, ' '), 32); + return handle === '' ? '@campus' : handle; +} +function plainAvatarImageSourceToken(value) { + if (typeof value !== 'string') + return null; + return /^profile_avatar_ref_v1_[A-Za-z0-9_-]{24}$/u.test(value) + ? value + : null; +} +function plainPostImages(value) { + if (!Array.isArray(value)) + return []; + const images = []; + const seen = new Set(); + for (const candidate of value) { + if (images.length >= 4) + break; + const source = asObject(candidate); + const sourceToken = plainPostImageSourceToken(source.sourceToken); + const width = plainPostImageDimension(source.width); + const height = plainPostImageDimension(source.height); + if (sourceToken === null || width === null || height === null) + continue; + if (seen.has(sourceToken)) + continue; + seen.add(sourceToken); + images.push({ sourceToken, width, height }); + } + return images; +} +function plainPostImageSourceToken(value) { + return typeof value === 'string' && + /^host_image_ref_v1_[A-Za-z0-9_-]{24}$/u.test(value) + ? value + : null; +} +function plainPostImageDimension(value) { + return Number.isInteger(value) && + value >= 1 && + value <= 1200 + ? value + : null; +} +function safeSequence(value) { + return Number.isSafeInteger(value) && value >= 0 + ? value + : 0; +} +function truncateScalars(value, maximum) { + const scalars = Array.from(value); + return scalars.length <= maximum ? value : scalars.slice(0, maximum).join(''); +} +function truncateEncodedJsonString(value, maximumBytes) { + if (encodedJsonBytes(value) <= maximumBytes) + return value; + const scalars = Array.from(value); + let lower = 0; + let upper = scalars.length; + while (lower < upper) { + const midpoint = Math.floor((lower + upper + 1) / 2); + if (encodedJsonBytes(scalars.slice(0, midpoint).join('')) <= maximumBytes) { + lower = midpoint; + } + else { + upper = midpoint - 1; + } + } + return scalars.slice(0, lower).join(''); +} +function encodedJsonBytes(value) { + const encoded = JSON.stringify(value); + if (typeof encoded !== 'string') + return 0; + let bytes = 0; + for (const scalar of encoded) { + const codePoint = scalar.codePointAt(0) ?? 0; + bytes += codePoint <= 0x7f + ? 1 + : codePoint <= 0x7ff + ? 2 + : codePoint <= 0xffff + ? 3 + : 4; + } + return bytes; +} +function asObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value + : {}; +} diff --git a/extensions/io.flutterflow.campus.x-draft-card/src/render_shared.ts b/extensions/io.flutterflow.campus.x-draft-card/src/render_shared.ts new file mode 100644 index 0000000..97765f2 --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/src/render_shared.ts @@ -0,0 +1,298 @@ +import { + xTimelineCardWidth, + type XTimelineDevice, +} from './x_fidelity_profile.js'; + +export interface TwitterModel { + readonly schemaVersion: 1; + readonly text: string; + readonly images: readonly TwitterPostImage[]; + readonly actionSeq: number; + readonly lastActorRef: string; +} + +export interface TwitterPostImage { + readonly sourceToken: string; + readonly width: number; + readonly height: number; +} + +/** + * Trusted, host-resolved identity input. The extension never receives image + * bytes, a file path, profile URL, account token, or permission to fetch the + * avatar itself. The optional token is opaque and is resolved only by the host + * after this renderer returns an inert scene. + * + * The manifest declares the settings; Campus passes this finite, validated + * projection to the pure renderer as `inputs.settings`. + */ +export interface TwitterResolvedSettingsInput { + readonly displayName?: string; + readonly handle?: string; + readonly avatar?: Readonly<{ + readonly displayName: string; + readonly imageSourceToken?: string; + }>; +} + +export interface TwitterIdentity { + readonly displayName: string; + readonly handle: string; + readonly avatarImageSourceToken: string | null; + readonly initials: string; +} + +export interface TwitterView { + readonly inputs?: Readonly<{ + model?: unknown; + settings?: TwitterResolvedSettingsInput; + /** + * Host-resolved surface size in logical pixels. Read-only: Campus derives + * it from replicated tile geometry, so every peer resolves the same value. + * `height` is null here because the card's height is content-measured — + * reading back the height this render produced would have no fixed point. + */ + surface?: Readonly<{width: number | null; height: number | null}>; + }>; + readonly revision?: number; +} + +export const twitterTheme = Object.freeze({ + background: '#000000', + text: '#E7E9EA', + secondary: '#71767B', + separator: '#2F3336', + blue: '#1D9BF0', +}); + +/** + * The local drafting contract is intentionally simpler than X's unpublished + * current composer policy: Campus admits 280 Unicode scalar values. It never + * counts UTF-16 code units, so a supplementary-plane character is one slot. + * We do not claim X publish eligibility (notably URL and CJK weighting). + */ +export const twitterCharacterLimit = 280; +export const twitterInputMaxEncodedBytes = 2048; +const twitterStateMaxEncodedBytes = 65536; + +export function twitterModel(view: TwitterView): TwitterModel { + const raw = asObject(view.inputs?.model); + return { + schemaVersion: 1, + text: plainDraftText(raw.text), + images: plainPostImages(raw.images), + actionSeq: safeSequence(raw.actionSeq), + lastActorRef: plainText(raw.lastActorRef, 'extension', 64), + }; +} + +/** + * Picks the fidelity profile that fits the width Campus gave this surface. + * + * The threshold is the extension's own: Campus reports pixels and takes no + * position on where a layout should change. Below the native web column the + * card renders x.com's narrow profile rather than being squeezed; a missing or + * nonsensical width falls back to `web`, matching the fail-closed style of the + * other normalizers here. + */ +export function resolveTwitterDevice(view: TwitterView): XTimelineDevice { + const width = view.inputs?.surface?.width; + if (typeof width !== 'number' || !Number.isFinite(width)) return 'web'; + return width < xTimelineCardWidth('web') ? 'mobile' : 'web'; +} + +/** + * The width to actually draw the card at. + * + * The device profile chooses the STRUCTURE (which name/handle maxima, which + * anatomy); this chooses the extent. They are separate on purpose: Campus + * reports a continuous width, so snapping the card back to the nearest native + * profile would reintroduce the gutter that filling the tile is meant to + * remove. Falls back to the native web column when no width is published. + */ +export function resolveTwitterCardWidth(view: TwitterView): number { + const width = view.inputs?.surface?.width; + if (typeof width !== 'number' || !Number.isFinite(width)) { + return xTimelineCardWidth('web'); + } + // Campus already clamps to the manifest bounds; clamp again so a hostile or + // stale value can never produce a degenerate card. + const minimum = xTimelineCardWidth('mobile'); + const maximum = xTimelineCardWidth('web'); + return Math.min(Math.max(Math.round(width), minimum), maximum); +} + +export function resolveTwitterIdentity(view: TwitterView): TwitterIdentity { + const settings = asObject(view.inputs?.settings); + const avatar = asObject(settings.avatar); + const profileDisplayName = plainText(avatar.displayName, 'Campus', 50); + const displayName = plainDisplayName(settings.displayName, profileDisplayName); + const handle = plainHandle(settings.handle); + const avatarImageSourceToken = plainAvatarImageSourceToken( + avatar.imageSourceToken, + ); + const initials = displayName + .split(/\s+/u) + .filter(Boolean) + .slice(0, 2) + .map((part) => Array.from(part)[0]?.toUpperCase() ?? '') + .join(''); + return { + displayName, + handle, + avatarImageSourceToken, + initials: initials || 'C', + }; +} + +export function postInputEncodedBudget(model: TwitterModel) { + const stateBytes = encodedJsonBytes(model); + const currentTextBytes = encodedJsonBytes(model.text); + const available = twitterStateMaxEncodedBytes - stateBytes + currentTextBytes - 1024; + return Math.max( + Math.min(currentTextBytes, twitterInputMaxEncodedBytes), + Math.min(twitterInputMaxEncodedBytes, Math.max(2, available)), + ); +} + +/** Count Unicode scalar values, matching the host Input formatter exactly. */ +export function twitterCharacterCount(value: string) { + return Array.from(value).length; +} + +function plainDraftText(value: unknown) { + if (typeof value !== 'string') return ''; + return truncateEncodedJsonString( + truncateScalars( + value + .replace(/\r\n?/gu, '\n') + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, ' '), + twitterCharacterLimit, + ), + twitterInputMaxEncodedBytes, + ); +} + +function plainDisplayName(value: unknown, fallback: string) { + if (typeof value !== 'string') return fallback; + // Campus validates the declared bound and safe-text policy. Preserve the + // admitted setting byte-for-byte so Settings never lies about what renders. + // The replacement/bound is defense in depth for direct conformance calls. + const displayName = truncateScalars( + value.replace(/[\u0000-\u001f\u007f]/gu, ' '), + 50, + ); + return displayName === '' ? fallback : displayName; +} + +function plainText(value: unknown, fallback: string, maxLength: number) { + if (typeof value !== 'string') return fallback; + const text = truncateScalars( + value.replace(/[\u0000-\u001f\u007f]/gu, ' ').trim(), + maxLength, + ); + return text || fallback; +} + +function plainHandle(value: unknown) { + if (typeof value !== 'string') return '@campus'; + // Campus has already applied the signed schema's bounds and safe-text + // checks. Preserve every admitted value exactly so Settings never claims a + // value that the card silently rewrites. The defensive replacement and + // bound cover direct renderer-conformance calls outside that host boundary. + const handle = truncateScalars( + value.replace(/[\u0000-\u001f\u007f]/gu, ' '), + 32, + ); + return handle === '' ? '@campus' : handle; +} + +function plainAvatarImageSourceToken(value: unknown) { + if (typeof value !== 'string') return null; + return /^profile_avatar_ref_v1_[A-Za-z0-9_-]{24}$/u.test(value) + ? value + : null; +} + +function plainPostImages(value: unknown): readonly TwitterPostImage[] { + if (!Array.isArray(value)) return []; + const images: TwitterPostImage[] = []; + const seen = new Set(); + for (const candidate of value) { + if (images.length >= 4) break; + const source = asObject(candidate); + const sourceToken = plainPostImageSourceToken(source.sourceToken); + const width = plainPostImageDimension(source.width); + const height = plainPostImageDimension(source.height); + if (sourceToken === null || width === null || height === null) continue; + if (seen.has(sourceToken)) continue; + seen.add(sourceToken); + images.push({sourceToken, width, height}); + } + return images; +} + +function plainPostImageSourceToken(value: unknown) { + return typeof value === 'string' && + /^host_image_ref_v1_[A-Za-z0-9_-]{24}$/u.test(value) + ? value + : null; +} + +function plainPostImageDimension(value: unknown) { + return Number.isInteger(value) && + (value as number) >= 1 && + (value as number) <= 1200 + ? value as number + : null; +} + +function safeSequence(value: unknown) { + return Number.isSafeInteger(value) && (value as number) >= 0 + ? value as number + : 0; +} + +function truncateScalars(value: string, maximum: number) { + const scalars = Array.from(value); + return scalars.length <= maximum ? value : scalars.slice(0, maximum).join(''); +} + +function truncateEncodedJsonString(value: string, maximumBytes: number) { + if (encodedJsonBytes(value) <= maximumBytes) return value; + const scalars = Array.from(value); + let lower = 0; + let upper = scalars.length; + while (lower < upper) { + const midpoint = Math.floor((lower + upper + 1) / 2); + if (encodedJsonBytes(scalars.slice(0, midpoint).join('')) <= maximumBytes) { + lower = midpoint; + } else { + upper = midpoint - 1; + } + } + return scalars.slice(0, lower).join(''); +} + +function encodedJsonBytes(value: unknown) { + const encoded = JSON.stringify(value); + if (typeof encoded !== 'string') return 0; + let bytes = 0; + for (const scalar of encoded) { + const codePoint = scalar.codePointAt(0) ?? 0; + bytes += codePoint <= 0x7f + ? 1 + : codePoint <= 0x7ff + ? 2 + : codePoint <= 0xffff + ? 3 + : 4; + } + return bytes; +} + +function asObject(value: unknown): Readonly> { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Readonly> + : {}; +} diff --git a/extensions/io.flutterflow.campus.x-draft-card/src/x_fidelity_profile.js b/extensions/io.flutterflow.campus.x-draft-card/src/x_fidelity_profile.js new file mode 100644 index 0000000..eda9c48 --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/src/x_fidelity_profile.js @@ -0,0 +1,117 @@ +/** + * Frozen, measurable geometry for the native x.com web timeline card. + * + * The values were captured on 2026-07-23 from X's first-party web build + * `5c1e3990a6aac9bf99d818f7a304102e3ce0ab42`. They are deliberately data, + * rather than scattered JSX literals, so a future X change creates an + * explicit profile update instead of silently redefining "pixel perfect". + * + * This profile describes card anatomy only. The containing extension is + * explicitly a local draft tool, omits the X mark, performs no X network + * request, and does not claim that a fabricated draft exists on X. + */ +export const xWebTimelineProfile = Object.freeze({ + id: 'x-web-timeline@2026-07-23', + sourceBuild: '5c1e3990a6aac9bf99d818f7a304102e3ce0ab42', + sourceStylesheet: 'https://abs.twimg.com/x-web/x-web/assets/styles-BvlbhhDZ.css', + widths: Object.freeze({ web: 598, mobile: 430 }), + card: Object.freeze({ + paddingHorizontal: 16, + paddingVertical: 12, + avatarSize: 40, + avatarContentGap: 8, + copyGap: 2, + contentGap: 12, + }), + text: Object.freeze({ + font: 'systemSans', + referenceFont: 'TwitterChirp', + referenceHandleFeature: 'ss01', + fidelityStatus: 'licensed-font-required', + bodySize: 15, + bodyLineHeight: 20, + nameWeight: 700, + bodyWeight: 400, + metricSize: 13, + metricLineHeight: 16, + }), + header: Object.freeze({ + verifiedSize: 15, + verifiedViewBox: 22, + nameBadgeGap: 4, + timestampInlinePadding: 4, + moreTargetSize: 32, + moreIconSize: 18, + moreViewBox: 24, + nameMaxWidth: Object.freeze({ web: 240, mobile: 120 }), + handleMaxWidth: Object.freeze({ web: 120, mobile: 94 }), + }), + media: Object.freeze({ + aspectWidth: 16, + aspectHeight: 9, + radius: 16, + borderWidth: 1, + seam: 2, + portraitHeightCap: 510, + }), + engagement: Object.freeze({ + leadingOffset: -2, + targetSize: 36, + iconSize: 18.75, + iconViewBox: 24, + countGap: 4, + }), + thread: Object.freeze({ + connectorWidth: 2, + connectorCenterX: 20, + connectorOvershoot: 12, + }), +}); +/** + * Campus-only editor behavior layered onto the captured timeline anatomy. + * + * X timeline copy is content-height rather than a fixed four-line box. Campus + * still needs a finite native allocation bound, so the draft grows from one + * line through 25 visible lines and then scrolls. At the captured 20 px line + * height, 25 lines keep the complete one-image card inside the signed 900 px + * surface maximum (397.375 + 25 * 20 = 897.375). + */ +export const twitterDraftEditorProfile = Object.freeze({ + minVisibleLines: 1, + maxVisibleLines: 25, +}); +export function xTimelineCardWidth(device) { + return xWebTimelineProfile.widths[device]; +} +export function xTimelineContentWidth(device) { + return xTimelineContentWidthForCard(xTimelineCardWidth(device)); +} +/** + * The copy column for a card rendered at an arbitrary width. + * + * The native profiles are the two widths x.com itself ships, but Campus hands + * the surface whatever width the user dragged to. Deriving the column from + * that width keeps the card filling its tile instead of snapping back to the + * nearest profile and leaving a gutter. + */ +export function xTimelineContentWidthForCard(cardWidth) { + return cardWidth + - xWebTimelineProfile.card.paddingHorizontal * 2 + - xWebTimelineProfile.card.avatarSize + - xWebTimelineProfile.card.avatarContentGap; +} +export function xTimelineMediaHeight(device) { + return xTimelineMediaHeightForContent(xTimelineContentWidth(device)); +} +/** Media keeps X's 16:9 frame at whatever column width it is given. */ +export function xTimelineMediaHeightForContent(contentWidth) { + return contentWidth + * xWebTimelineProfile.media.aspectHeight + / xWebTimelineProfile.media.aspectWidth; +} +export function xTimelineNameMaxWidth(device) { + return xWebTimelineProfile.header.nameMaxWidth[device]; +} +export function xTimelineHandleMaxWidth(device) { + return xWebTimelineProfile.header.handleMaxWidth[device]; +} diff --git a/extensions/io.flutterflow.campus.x-draft-card/src/x_fidelity_profile.ts b/extensions/io.flutterflow.campus.x-draft-card/src/x_fidelity_profile.ts new file mode 100644 index 0000000..659ffef --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/src/x_fidelity_profile.ts @@ -0,0 +1,129 @@ +/** + * Frozen, measurable geometry for the native x.com web timeline card. + * + * The values were captured on 2026-07-23 from X's first-party web build + * `5c1e3990a6aac9bf99d818f7a304102e3ce0ab42`. They are deliberately data, + * rather than scattered JSX literals, so a future X change creates an + * explicit profile update instead of silently redefining "pixel perfect". + * + * This profile describes card anatomy only. The containing extension is + * explicitly a local draft tool, omits the X mark, performs no X network + * request, and does not claim that a fabricated draft exists on X. + */ + +export const xWebTimelineProfile = Object.freeze({ + id: 'x-web-timeline@2026-07-23', + sourceBuild: '5c1e3990a6aac9bf99d818f7a304102e3ce0ab42', + sourceStylesheet: + 'https://abs.twimg.com/x-web/x-web/assets/styles-BvlbhhDZ.css', + widths: Object.freeze({web: 598, mobile: 430}), + card: Object.freeze({ + paddingHorizontal: 16, + paddingVertical: 12, + avatarSize: 40, + avatarContentGap: 8, + copyGap: 2, + contentGap: 12, + }), + text: Object.freeze({ + font: 'systemSans' as const, + referenceFont: 'TwitterChirp', + referenceHandleFeature: 'ss01', + fidelityStatus: 'licensed-font-required', + bodySize: 15, + bodyLineHeight: 20, + nameWeight: 700 as const, + bodyWeight: 400 as const, + metricSize: 13, + metricLineHeight: 16, + }), + header: Object.freeze({ + verifiedSize: 15, + verifiedViewBox: 22, + nameBadgeGap: 4, + timestampInlinePadding: 4, + moreTargetSize: 32, + moreIconSize: 18, + moreViewBox: 24, + nameMaxWidth: Object.freeze({web: 240, mobile: 120}), + handleMaxWidth: Object.freeze({web: 120, mobile: 94}), + }), + media: Object.freeze({ + aspectWidth: 16, + aspectHeight: 9, + radius: 16, + borderWidth: 1, + seam: 2, + portraitHeightCap: 510, + }), + engagement: Object.freeze({ + leadingOffset: -2, + targetSize: 36, + iconSize: 18.75, + iconViewBox: 24, + countGap: 4, + }), + thread: Object.freeze({ + connectorWidth: 2, + connectorCenterX: 20, + connectorOvershoot: 12, + }), +}); + +/** + * Campus-only editor behavior layered onto the captured timeline anatomy. + * + * X timeline copy is content-height rather than a fixed four-line box. Campus + * still needs a finite native allocation bound, so the draft grows from one + * line through 25 visible lines and then scrolls. At the captured 20 px line + * height, 25 lines keep the complete one-image card inside the signed 900 px + * surface maximum (397.375 + 25 * 20 = 897.375). + */ +export const twitterDraftEditorProfile = Object.freeze({ + minVisibleLines: 1, + maxVisibleLines: 25, +}); + +export type XTimelineDevice = keyof typeof xWebTimelineProfile.widths; + +export function xTimelineCardWidth(device: XTimelineDevice) { + return xWebTimelineProfile.widths[device]; +} + +export function xTimelineContentWidth(device: XTimelineDevice) { + return xTimelineContentWidthForCard(xTimelineCardWidth(device)); +} + +/** + * The copy column for a card rendered at an arbitrary width. + * + * The native profiles are the two widths x.com itself ships, but Campus hands + * the surface whatever width the user dragged to. Deriving the column from + * that width keeps the card filling its tile instead of snapping back to the + * nearest profile and leaving a gutter. + */ +export function xTimelineContentWidthForCard(cardWidth: number) { + return cardWidth + - xWebTimelineProfile.card.paddingHorizontal * 2 + - xWebTimelineProfile.card.avatarSize + - xWebTimelineProfile.card.avatarContentGap; +} + +export function xTimelineMediaHeight(device: XTimelineDevice) { + return xTimelineMediaHeightForContent(xTimelineContentWidth(device)); +} + +/** Media keeps X's 16:9 frame at whatever column width it is given. */ +export function xTimelineMediaHeightForContent(contentWidth: number) { + return contentWidth + * xWebTimelineProfile.media.aspectHeight + / xWebTimelineProfile.media.aspectWidth; +} + +export function xTimelineNameMaxWidth(device: XTimelineDevice) { + return xWebTimelineProfile.header.nameMaxWidth[device]; +} + +export function xTimelineHandleMaxWidth(device: XTimelineDevice) { + return xWebTimelineProfile.header.handleMaxWidth[device]; +} diff --git a/extensions/io.flutterflow.campus.x-draft-card/src/x_icon_paths.js b/extensions/io.flutterflow.campus.x-draft-card/src/x_icon_paths.js new file mode 100644 index 0000000..c620867 --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/src/x_icon_paths.js @@ -0,0 +1,125 @@ +/** + * Inactive native-timeline icon paths frozen with x-web-timeline@2026-07-22. + * + * X renders these as non-zero compound fills in a 24 px view box (the + * verified badge is 22 px). Keeping the source path strings here lets the + * fidelity fixture be audited against the captured first-party markup. A + * public product using these assets still needs the same IP review as any + * copied interface asset. + */ +export const xWebTimelineIconPaths = Object.freeze({ + verified: 'M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z', + more: 'M3 12c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2zm9 2c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm7 0c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z', + reply: 'M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z', + repost: 'M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z', + like: 'M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z', + views: 'M8.75 21V3h2v18h-2zM18 21V8.5h2V21h-2zM4 21l.004-10h2L6 21H4zm9.248 0v-7h2v7h-2z', + bookmark: 'M4 4.5C4 3.12 5.119 2 6.5 2h11C18.881 2 20 3.12 20 4.5v18.44l-8-5.71-8 5.71V4.5zM6.5 4c-.276 0-.5.22-.5.5v14.56l6-4.29 6 4.29V4.5c0-.28-.224-.5-.5-.5h-11z', + share: 'M12 2.59l5.7 5.7-1.41 1.42L13 6.41V16h-2V6.41l-3.3 3.3-1.41-1.42L12 2.59zM21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z', +}); +const parsedPaths = Object.freeze(Object.fromEntries(Object.entries(xWebTimelineIconPaths).map(([name, path]) => [ + name, + Object.freeze(parseSvgPath(path)), +]))); +export function xIconCommands(icon, color) { + return [{ + op: 'path', + commands: parsedPaths[icon], + fill: color, + fillRule: 'nonZero', + }]; +} +/** Parses the finite M/L/H/V/C/S/Z subset used by the frozen paths above. */ +function parseSvgPath(source) { + const tokens = source.match(/[A-Za-z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/gu) ?? []; + const commands = []; + let index = 0; + let command = ''; + let previousCommand = ''; + let x = 0; + let y = 0; + let startX = 0; + let startY = 0; + let lastCubicX = 0; + let lastCubicY = 0; + const isCommand = (token) => /^[A-Za-z]$/u.test(token); + const number = () => { + const token = tokens[index++]; + if (token === undefined || isCommand(token)) { + throw new Error('Invalid frozen SVG path.'); + } + const value = Number(token); + if (!Number.isFinite(value)) + throw new Error('Non-finite frozen SVG path.'); + return value; + }; + const relativeX = (value, relative, origin) => relative ? origin + value : value; + const relativeY = relativeX; + while (index < tokens.length) { + if (isCommand(tokens[index])) + command = tokens[index++]; + if (command === '') + throw new Error('Frozen SVG path has no command.'); + const relative = command === command.toLowerCase(); + const upper = command.toUpperCase(); + const originX = x; + const originY = y; + if (upper === 'Z') { + commands.push({ op: 'Z' }); + x = startX; + y = startY; + previousCommand = upper; + command = ''; + continue; + } + if (upper === 'M' || upper === 'L') { + x = relativeX(number(), relative, originX); + y = relativeY(number(), relative, originY); + commands.push({ op: upper === 'M' ? 'M' : 'L', x, y }); + if (upper === 'M') { + startX = x; + startY = y; + command = relative ? 'l' : 'L'; + } + } + else if (upper === 'H') { + x = relativeX(number(), relative, originX); + commands.push({ op: 'L', x, y }); + } + else if (upper === 'V') { + y = relativeY(number(), relative, originY); + commands.push({ op: 'L', x, y }); + } + else if (upper === 'C') { + const x1 = relativeX(number(), relative, originX); + const y1 = relativeY(number(), relative, originY); + const x2 = relativeX(number(), relative, originX); + const y2 = relativeY(number(), relative, originY); + x = relativeX(number(), relative, originX); + y = relativeY(number(), relative, originY); + commands.push({ op: 'C', x1, y1, x2, y2, x3: x, y3: y }); + lastCubicX = x2; + lastCubicY = y2; + } + else if (upper === 'S') { + const x1 = previousCommand === 'C' || previousCommand === 'S' + ? x * 2 - lastCubicX + : x; + const y1 = previousCommand === 'C' || previousCommand === 'S' + ? y * 2 - lastCubicY + : y; + const x2 = relativeX(number(), relative, originX); + const y2 = relativeY(number(), relative, originY); + x = relativeX(number(), relative, originX); + y = relativeY(number(), relative, originY); + commands.push({ op: 'C', x1, y1, x2, y2, x3: x, y3: y }); + lastCubicX = x2; + lastCubicY = y2; + } + else { + throw new Error(`Unsupported frozen SVG path command: ${command}`); + } + previousCommand = upper; + } + return commands; +} diff --git a/extensions/io.flutterflow.campus.x-draft-card/src/x_icon_paths.ts b/extensions/io.flutterflow.campus.x-draft-card/src/x_icon_paths.ts new file mode 100644 index 0000000..e2345b9 --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/src/x_icon_paths.ts @@ -0,0 +1,143 @@ +import type {CanvasCommand, PathCommand, SceneColor} from 'campus:ui'; + +/** + * Inactive native-timeline icon paths frozen with x-web-timeline@2026-07-22. + * + * X renders these as non-zero compound fills in a 24 px view box (the + * verified badge is 22 px). Keeping the source path strings here lets the + * fidelity fixture be audited against the captured first-party markup. A + * public product using these assets still needs the same IP review as any + * copied interface asset. + */ +export const xWebTimelineIconPaths = Object.freeze({ + verified: + 'M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z', + more: + 'M3 12c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2zm9 2c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm7 0c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z', + reply: + 'M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z', + repost: + 'M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z', + like: + 'M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z', + views: + 'M8.75 21V3h2v18h-2zM18 21V8.5h2V21h-2zM4 21l.004-10h2L6 21H4zm9.248 0v-7h2v7h-2z', + bookmark: + 'M4 4.5C4 3.12 5.119 2 6.5 2h11C18.881 2 20 3.12 20 4.5v18.44l-8-5.71-8 5.71V4.5zM6.5 4c-.276 0-.5.22-.5.5v14.56l6-4.29 6 4.29V4.5c0-.28-.224-.5-.5-.5h-11z', + share: + 'M12 2.59l5.7 5.7-1.41 1.42L13 6.41V16h-2V6.41l-3.3 3.3-1.41-1.42L12 2.59zM21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z', +}); + +export type XWebTimelineIcon = keyof typeof xWebTimelineIconPaths; + +const parsedPaths = Object.freeze( + Object.fromEntries( + Object.entries(xWebTimelineIconPaths).map(([name, path]) => [ + name, + Object.freeze(parseSvgPath(path)), + ]), + ) as Readonly>, +); + +export function xIconCommands( + icon: XWebTimelineIcon, + color: SceneColor, +): CanvasCommand[] { + return [{ + op: 'path', + commands: parsedPaths[icon], + fill: color, + fillRule: 'nonZero', + }]; +} + +/** Parses the finite M/L/H/V/C/S/Z subset used by the frozen paths above. */ +function parseSvgPath(source: string): PathCommand[] { + const tokens = source.match(/[A-Za-z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/gu) ?? []; + const commands: PathCommand[] = []; + let index = 0; + let command = ''; + let previousCommand = ''; + let x = 0; + let y = 0; + let startX = 0; + let startY = 0; + let lastCubicX = 0; + let lastCubicY = 0; + + const isCommand = (token: string) => /^[A-Za-z]$/u.test(token); + const number = () => { + const token = tokens[index++]; + if (token === undefined || isCommand(token)) { + throw new Error('Invalid frozen SVG path.'); + } + const value = Number(token); + if (!Number.isFinite(value)) throw new Error('Non-finite frozen SVG path.'); + return value; + }; + const relativeX = (value: number, relative: boolean, origin: number) => + relative ? origin + value : value; + const relativeY = relativeX; + + while (index < tokens.length) { + if (isCommand(tokens[index])) command = tokens[index++]; + if (command === '') throw new Error('Frozen SVG path has no command.'); + const relative = command === command.toLowerCase(); + const upper = command.toUpperCase(); + const originX = x; + const originY = y; + + if (upper === 'Z') { + commands.push({op: 'Z'}); + x = startX; + y = startY; + previousCommand = upper; + command = ''; + continue; + } + if (upper === 'M' || upper === 'L') { + x = relativeX(number(), relative, originX); + y = relativeY(number(), relative, originY); + commands.push({op: upper === 'M' ? 'M' : 'L', x, y}); + if (upper === 'M') { + startX = x; + startY = y; + command = relative ? 'l' : 'L'; + } + } else if (upper === 'H') { + x = relativeX(number(), relative, originX); + commands.push({op: 'L', x, y}); + } else if (upper === 'V') { + y = relativeY(number(), relative, originY); + commands.push({op: 'L', x, y}); + } else if (upper === 'C') { + const x1 = relativeX(number(), relative, originX); + const y1 = relativeY(number(), relative, originY); + const x2 = relativeX(number(), relative, originX); + const y2 = relativeY(number(), relative, originY); + x = relativeX(number(), relative, originX); + y = relativeY(number(), relative, originY); + commands.push({op: 'C', x1, y1, x2, y2, x3: x, y3: y}); + lastCubicX = x2; + lastCubicY = y2; + } else if (upper === 'S') { + const x1 = previousCommand === 'C' || previousCommand === 'S' + ? x * 2 - lastCubicX + : x; + const y1 = previousCommand === 'C' || previousCommand === 'S' + ? y * 2 - lastCubicY + : y; + const x2 = relativeX(number(), relative, originX); + const y2 = relativeY(number(), relative, originY); + x = relativeX(number(), relative, originX); + y = relativeY(number(), relative, originY); + commands.push({op: 'C', x1, y1, x2, y2, x3: x, y3: y}); + lastCubicX = x2; + lastCubicY = y2; + } else { + throw new Error(`Unsupported frozen SVG path command: ${command}`); + } + previousCommand = upper; + } + return commands; +} diff --git a/extensions/io.flutterflow.campus.x-draft-card/test/twitter_draft_studio.test.mjs b/extensions/io.flutterflow.campus.x-draft-card/test/twitter_draft_studio.test.mjs new file mode 100644 index 0000000..7b8515d --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/test/twitter_draft_studio.test.mjs @@ -0,0 +1,487 @@ +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {readFile} from 'node:fs/promises'; +import test from 'node:test'; + +import { + actionIds, + applyDraftAction, + createInitialState, + draftLimits, + encodedJsonBytes, + normalizeState, + truncateEncodedJsonString, + truncateScalars, +} from '../src/model.js'; +import { + actions, + assetInputs, + sharedTexts, +} from '../src/campus.generated.mjs'; +import {activate} from '../src/main.js'; +import { + resolveTwitterCardWidth, + resolveTwitterDevice, + resolveTwitterIdentity, + twitterCharacterCount, + twitterCharacterLimit, + twitterModel, +} from '../src/render_shared.js'; +import { + twitterDraftEditorProfile, + xTimelineCardWidth, + xTimelineContentWidth, + xTimelineContentWidthForCard, + xTimelineHandleMaxWidth, + xTimelineMediaHeightForContent, + xTimelineMediaHeight, + xTimelineNameMaxWidth, + xWebTimelineProfile, +} from '../src/x_fidelity_profile.js'; +import { + xIconCommands, + xWebTimelineIconPaths, +} from '../src/x_icon_paths.js'; + +const manifestUrl = new URL('../campus.extension.json', import.meta.url); + +test('manifest exposes one least-authority draft card and no studio commands', async () => { + const manifest = JSON.parse(await readFile(manifestUrl, 'utf8')); + assert.equal(manifest.schemaVersion, 3); + assert.equal(manifest.id, 'io.flutterflow.campus.x-draft-card'); + assert.equal(manifest.version, '0.5.0'); + assert.deepEqual(manifest.capabilities.maximum, [ + 'input.image.drop', + 'state.shared.write', + 'surface.scene.write', + ]); + assert.deepEqual(manifest.capabilities.required, manifest.capabilities.maximum); + assert.deepEqual(manifest.resources, {}); + assert.deepEqual(manifest.tasks, {}); + assert.deepEqual(Object.keys(manifest.statePools), ['draftState']); + assert.deepEqual(Object.keys(manifest.surfacePools), ['card']); + assert.equal(manifest.surfacePools.card.statePool, 'draftState'); + assert.equal(manifest.surfacePools.card.renderer, 'src/render_card.js#render'); + // The card is user-resizable across the range it can actually render, and + // Campus refuses a drag outside it — so there is no width at which the card + // is stranded in a larger tile. Height stays host-measured. + assert.deepEqual(manifest.surfacePools.card.layout, { + width: {strategy: 'fill'}, + height: {strategy: 'content'}, + minSize: {width: 430, height: 124}, + maxSize: {width: 598, height: 900}, + }); + assert.equal(Object.hasOwn(manifest.surfacePools.card, 'layoutModes'), false); + assert.equal(Object.hasOwn(manifest.surfacePools.card, 'defaultLayoutMode'), false); + assert.deepEqual(manifest.sharedTexts, { + postText: { + statePool: 'draftState', + stateField: 'text', + audience: 'interactors', + maxScalars: 280, + maxEncodedBytes: 2048, + }, + }); + assert.deepEqual(manifest.actions, { + addPostImages: { + effect: 'sharedDocumentMutation', + audience: 'owner', + }, + removePostImage: { + effect: 'sharedDocumentMutation', + audience: 'interactors', + }, + }); + assert.deepEqual(actionIds, [ + actions.addPostImages, + actions.removePostImage, + ]); + assert.equal(Object.hasOwn(manifest, 'peerActions'), false); + assert.equal(Object.hasOwn(manifest, 'actionEffects'), false); + assert.equal(Object.hasOwn(manifest.contributes, 'commands'), false); + assert.equal(manifest.contributes.launchers.length, 1); + assert.deepEqual( + manifest.contributes.launchers[0].surfaces.map((surface) => surface.id), + ['twitter.card'], + ); + assert.deepEqual(manifest.contributes.assetInputs, [ + { + id: 'post-images', + surfacePool: 'card', + kind: 'image', + accept: ['image/jpeg', 'image/png'], + maxItems: 4, + maxSourceBytes: 8 * 1024 * 1024, + action: 'addPostImages', + label: 'Drop images to attach', + }, + ]); + assert.equal(Object.hasOwn(manifest.contributes, 'fileDrops'), false); + assert.equal(sharedTexts.postText, 'postText'); + assert.equal(assetInputs['post-images'], 'post-images'); + assert.equal(manifest.contributes.launchers[0].surfaces[0].defaultWidth, 598); + assert.equal(manifest.contributes.launchers[0].surfaces[0].defaultHeight, 124); +}); + +test('manifest settings are host-rendered and inherit from global to tile scope', async () => { + const manifest = JSON.parse(await readFile(manifestUrl, 'utf8')); + const displayName = manifest.contributes.settings.find( + (setting) => setting.id === 'displayName', + ); + const handle = manifest.contributes.settings.find((setting) => setting.id === 'handle'); + const avatar = manifest.contributes.settings.find((setting) => setting.id === 'avatar'); + assert.deepEqual(displayName, { + id: 'displayName', + type: 'string', + label: 'Name', + description: + 'The display name shown on X draft cards. When unset, the Campus profile name is used.', + allowedScopes: ['global', 'tile'], + minLength: 1, + maxLength: 50, + requireTrimmed: true, + requireNonBlank: true, + placeholder: 'Campus profile name', + }); + assert.deepEqual(handle.allowedScopes, ['global', 'tile']); + assert.equal(handle.type, 'string'); + assert.equal(handle.default, '@campus'); + assert.equal(handle.minLength, 1); + assert.equal(handle.maxLength, 32); + assert.equal(handle.requireTrimmed, true); + assert.equal(handle.requireNonBlank, true); + assert.deepEqual(avatar, { + id: 'avatar', + type: 'profileAvatar', + label: 'Avatar', + description: 'The avatar shown on X draft cards.', + allowedScopes: ['global'], + default: 'campusProfile', + }); +}); + +test('activation canonicalizes one state document and opens one card surface', async () => { + const model = {snapshot: {schemaVersion: 1, text: 'hello\r\nworld\u0000', actionSeq: -2}}; + const opened = []; + const handlers = new Map(); + const patches = []; + const ctx = { + actions: { + handle: async (id, handler) => handlers.set(id, handler), + }, + state: { + open: async (pool, key) => { + assert.equal(pool, 'draftState'); + assert.equal(key, 'main'); + return model; + }, + patch: async (_, patch) => { + patches.push(patch); + model.snapshot = {...model.snapshot, ...patch}; + return null; + }, + }, + ui: {openSurface: async (surface) => opened.push(surface)}, + }; + + const result = await activate(ctx); + assert.deepEqual([...handlers.keys()], actionIds); + assert.equal(patches.length, 1); + assert.equal(model.snapshot.text, 'hello\nworld '); + assert.equal(model.snapshot.actionSeq, 0); + assert.deepEqual(opened.map((surface) => surface.id), ['twitter.card']); + assert.deepEqual(opened.map((surface) => surface.pool), ['card']); + assert.deepEqual(result, {surfaces: ['twitter.card']}); +}); + +test('shared text is host-owned and cannot be reintroduced as a guest action', async () => { + assert.equal(Object.hasOwn(actions, 'updatePostText'), false); + assert.equal(actionIds.includes('updatePostText'), false); + assert.equal( + applyDraftAction( + createInitialState(), + 'updatePostText', + {value: 'forged guest text'}, + 'peer-a', + ), + null, + ); + + const coordinatorSource = await readFile( + new URL('../src/main.js', import.meta.url), + 'utf8', + ); + assert.doesNotMatch(coordinatorSource, /updatePostText/u); +}); + +test('host-issued opaque images are bounded, deduplicated, and removable', () => { + const first = 'host_image_ref_v1_000000000000000000000000'; + const second = 'host_image_ref_v1_111111111111111111111111'; + const malformed = 'https://example.invalid/not-an-opaque-token.png'; + const added = applyDraftAction( + createInitialState(), + 'addPostImages', + { + images: [ + {sourceToken: first, width: 1200, height: 600}, + {sourceToken: first, width: 1200, height: 600}, + {sourceToken: malformed, width: 1, height: 1}, + {sourceToken: second, width: 640, height: 640}, + ], + }, + 'owner', + ); + assert.deepEqual(added.images, [ + {sourceToken: first, width: 1200, height: 600}, + {sourceToken: second, width: 640, height: 640}, + ]); + const removed = applyDraftAction( + added, + 'removePostImage', + {sourceToken: first}, + 'peer-a', + ); + assert.deepEqual(removed.images, [ + {sourceToken: second, width: 640, height: 640}, + ]); + assert.equal( + applyDraftAction(removed, 'removePostImage', {sourceToken: malformed}, 'peer-a'), + null, + ); +}); + +test('Unicode and encoded-byte bounds preserve complete scalars', () => { + assert.equal(draftLimits.maxPostScalars, twitterCharacterLimit); + assert.equal(twitterCharacterCount('A😀e\u0301'), 4); + assert.equal(truncateScalars('😀😀x', 2), '😀😀'); + const bounded = truncateEncodedJsonString('😀'.repeat(9000), draftLimits.maxPostEncodedBytes); + assert.ok(encodedJsonBytes(bounded) <= draftLimits.maxPostEncodedBytes); + assert.ok(encodedJsonBytes(bounded + '😀') > draftLimits.maxPostEncodedBytes); + + const state = normalizeState({ + ...createInitialState(), + text: '😀'.repeat(30000), + }); + assert.ok(Array.from(state.text).length <= draftLimits.maxPostScalars); + assert.ok(encodedJsonBytes(state.text) <= draftLimits.maxPostEncodedBytes); + assert.ok(encodedJsonBytes(state) <= draftLimits.maxStateEncodedBytes); + assert.equal(twitterCharacterCount(state.text), twitterCharacterLimit); +}); + +test('renderer normalization has safe identity defaults and rejects remote avatar URLs', () => { + assert.deepEqual(twitterModel({inputs: {model: {text: 'draft'}}}), { + schemaVersion: 1, + text: 'draft', + images: [], + actionSeq: 0, + lastActorRef: 'extension', + }); + assert.deepEqual(resolveTwitterIdentity({}), { + displayName: 'Campus', + handle: '@campus', + avatarImageSourceToken: null, + initials: 'C', + }); + assert.deepEqual( + resolveTwitterIdentity({ + inputs: { + settings: { + displayName: 'Countess of Lovelace', + handle: '@ada-campus', + avatar: { + displayName: 'Ada Lovelace', + imageDataUrl: 'https://example.invalid/avatar.png', + }, + }, + }, + }), + { + displayName: 'Countess of Lovelace', + handle: '@ada-campus', + avatarImageSourceToken: null, + initials: 'CO', + }, + ); + assert.equal( + resolveTwitterIdentity({ + inputs: { + settings: { + avatar: {displayName: 'Campus Profile Name'}, + }, + }, + }).displayName, + 'Campus Profile Name', + 'an unset extension name inherits the host-projected Campus profile name', + ); + assert.equal( + resolveTwitterIdentity({ + inputs: { + settings: { + avatar: { + displayName: 'Campus', + imageDataUrl: 'data:image/png;base64,AA==', + }, + }, + }, + }).avatarImageSourceToken, + null, + 'raw profile bytes are never a renderer-visible image source', + ); + assert.equal( + resolveTwitterIdentity({ + inputs: { + settings: { + avatar: { + displayName: 'Campus', + imageSourceToken: + 'profile_avatar_ref_v1_000000000000000000000000', + }, + }, + }, + }).avatarImageSourceToken, + 'profile_avatar_ref_v1_000000000000000000000000', + ); +}); + +test('card binds host-owned text and a layout-neutral asset admission marker', async () => { + const source = await readFile( + new URL('../src/render_card.js', import.meta.url), + 'utf8', + ); + assert.match(source, /sharedText: sharedTexts\.postText/u); + assert.match(source, /counter: "radialRemaining"/u); + assert.match(source, /counterWarningThreshold: 20/u); + assert.match(source, /removePostImage/u); + assert.match(source, /DropZone/u); + assert.match(source, /assetInput: assetInputs\['post-images'\]/u); + assert.match(source, /width: 1, height: 1/u); + assert.doesNotMatch(source, /layoutMode/u); + assert.doesNotMatch(source, /layoutModes/u); + assert.match(source, /minLines: twitterDraftEditorProfile\.minVisibleLines/u); + assert.match(source, /maxLines: twitterDraftEditorProfile\.maxVisibleLines/u); + assert.doesNotMatch(source, /composerVisibleLines/u); + assert.doesNotMatch(source, /updatePostText/u); + assert.doesNotMatch(source, /onSubmit:/u); + assert.doesNotMatch(source, /maxLength:/u); + assert.doesNotMatch(source, /twitter-card:separator/u); + const sharedSource = await readFile( + new URL('../src/render_shared.js', import.meta.url), + 'utf8', + ); + assert.match(sharedSource, /host_image_ref_v1_/u); +}); + +test('the card follows the width Campus gives it, both profiles', () => { + // Campus reports pixels and takes no position on breakpoints; the threshold + // below is the extension's own, and the profile it selects is the one x.com + // itself uses at that width. + assert.equal(resolveTwitterDevice({inputs: {surface: {width: 598}}}), 'web'); + assert.equal(resolveTwitterDevice({inputs: {surface: {width: 597}}}), 'mobile'); + assert.equal(resolveTwitterDevice({inputs: {surface: {width: 430}}}), 'mobile'); + + // A host that publishes no width (a surface with no host-owned axis), or a + // hostile value, must not break the card — it renders the native profile. + assert.equal(resolveTwitterDevice({}), 'web'); + assert.equal(resolveTwitterDevice({inputs: {surface: {width: null}}}), 'web'); + assert.equal( + resolveTwitterDevice({inputs: {surface: {width: Number.NaN}}}), + 'web', + ); + + // The narrow profile is a real relayout, not a squeeze: every derived + // dimension moves with it. + assert.equal(xTimelineCardWidth('mobile'), 430); + assert.equal(xTimelineContentWidth('mobile'), 350); + assert.equal(xTimelineMediaHeight('mobile'), 196.875); + assert.equal(xTimelineNameMaxWidth('mobile'), 120); + assert.equal(xTimelineHandleMaxWidth('mobile'), 94); + + // Both profiles must fit the signed maximum height, or the tallest draft + // would be clamped mid-render. + const tallest = (device) => + xWebTimelineProfile.card.paddingVertical * 2 + + xWebTimelineProfile.text.bodyLineHeight + + xWebTimelineProfile.card.copyGap + + twitterDraftEditorProfile.maxVisibleLines * + xWebTimelineProfile.text.bodyLineHeight + + xWebTimelineProfile.card.contentGap * 2 + + xTimelineMediaHeight(device) + + xWebTimelineProfile.engagement.targetSize; + assert.ok(tallest('mobile') <= 900); + assert.ok(tallest('web') <= 900); + + // The narrow profile is never asked to render wider than it was designed + // for: the manifest floor IS its card width. + assert.equal(xTimelineCardWidth('mobile'), 430); + + // Extent is continuous even though the profile is discrete: the card draws + // at the width Campus reported, so an in-between drag leaves no gutter. + assert.equal(resolveTwitterCardWidth({inputs: {surface: {width: 512}}}), 512); + assert.equal(xTimelineContentWidthForCard(512), 432); + assert.equal(xTimelineMediaHeightForContent(432), 243); + + // ...and is still bounded by what the anatomy can render. + assert.equal(resolveTwitterCardWidth({inputs: {surface: {width: 4000}}}), 598); + assert.equal(resolveTwitterCardWidth({inputs: {surface: {width: 10}}}), 430); + assert.equal(resolveTwitterCardWidth({}), 598); + + // The native columns still reproduce X's captured geometry exactly. + assert.equal(xTimelineContentWidthForCard(xTimelineCardWidth('web')), 518); + assert.equal(xTimelineContentWidthForCard(xTimelineCardWidth('mobile')), 350); +}); + +test('captured X anatomy and bounded intrinsic editor remain explicit', () => { + assert.equal(xWebTimelineProfile.id, 'x-web-timeline@2026-07-23'); + assert.equal( + xWebTimelineProfile.sourceBuild, + '5c1e3990a6aac9bf99d818f7a304102e3ce0ab42', + ); + assert.equal(xTimelineCardWidth('web'), 598); + assert.equal(xTimelineContentWidth('web'), 518); + assert.equal(xTimelineMediaHeight('web'), 291.375); + assert.equal(twitterDraftEditorProfile.minVisibleLines, 1); + assert.equal(twitterDraftEditorProfile.maxVisibleLines, 25); + const maximumOneImageHeight = + xWebTimelineProfile.card.paddingVertical * 2 + + xWebTimelineProfile.text.bodyLineHeight + + xWebTimelineProfile.card.copyGap + + twitterDraftEditorProfile.maxVisibleLines * + xWebTimelineProfile.text.bodyLineHeight + + xWebTimelineProfile.card.contentGap * 2 + + xTimelineMediaHeight('web') + + xWebTimelineProfile.engagement.targetSize; + assert.equal(maximumOneImageHeight, 897.375); + assert.ok(maximumOneImageHeight <= 900); + assert.equal(xTimelineNameMaxWidth('web'), 240); + assert.equal(xTimelineHandleMaxWidth('web'), 120); + assert.equal( + createHash('sha256') + .update(JSON.stringify(xWebTimelineIconPaths)) + .digest('hex'), + '7713ddcdd6e0c1a2eab289ec97df7065d07ed005108ae80f0c34fe170c0860f9', + ); + for (const icon of ['more', 'reply', 'repost', 'like', 'views', 'bookmark', 'share']) { + const commands = xIconCommands(icon, '#536471'); + assert.equal(commands.length, 1); + assert.equal(commands[0].op, 'path'); + assert.ok(commands[0].commands.length > 0); + } +}); + +test('normalizer remains deterministic for hostile restored values', () => { + assert.deepEqual(normalizeState(null), createInitialState()); + const normalized = normalizeState({ + schemaVersion: 99, + text: 'x\u0000y', + actionSeq: Number.POSITIVE_INFINITY, + lastActorRef: '\u0000', + ignoredAuthority: 'network', + }); + assert.deepEqual(normalized, { + schemaVersion: 1, + text: 'x y', + images: [], + actionSeq: 0, + lastActorRef: 'extension', + }); +}); diff --git a/extensions/io.flutterflow.campus.x-draft-card/tsconfig.json b/extensions/io.flutterflow.campus.x-draft-card/tsconfig.json new file mode 100644 index 0000000..9f55fde --- /dev/null +++ b/extensions/io.flutterflow.campus.x-draft-card/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "jsxImportSource": "campus:ui", + "strict": true, + "exactOptionalPropertyTypes": true, + "noEmitOnError": true, + "skipLibCheck": false, + "types": [], + "lib": ["ES2022"] + }, + "files": [ + "../../../packages/campus_extension_runtime/js_sdk/campus-js-1.d.ts", + "src/render_shared.ts", + "src/render_card.tsx" + ] +} diff --git a/tool/registry.py b/tool/registry.py index 24d0c8a..693b3a0 100644 --- a/tool/registry.py +++ b/tool/registry.py @@ -50,7 +50,7 @@ def _parser() -> argparse.ArgumentParser: publish = subcommands.add_parser( "publish", - help="pack one reviewed source release and update the canonical catalog", + help="pack one validated source release and update the canonical catalog", ) publish.add_argument("--root", type=Path, default=Path.cwd()) publish.add_argument("--extension", required=True) diff --git a/tool/tests/test_workflow_security.py b/tool/tests/test_workflow_security.py index def9f85..82575a3 100644 --- a/tool/tests/test_workflow_security.py +++ b/tool/tests/test_workflow_security.py @@ -17,7 +17,7 @@ class PublicationWorkflowSecurityTest(unittest.TestCase): def test_untrusted_tests_finish_before_write_token_exists(self) -> None: text = PREPARE_WORKFLOW.read_text(encoding="utf-8") - validate = text.index("Validate reviewed source and current catalog") + validate = text.index("Validate merged source and current catalog") verify_diff = text.index("Verify generated diff is publication-only") mint = text.index("Mint short-lived publication bot token")