diff --git a/clientlibs/js/ff-playground-app/.gitignore b/clientlibs/js/ff-playground-app/.gitignore
new file mode 100644
index 0000000000..b1d225e26e
--- /dev/null
+++ b/clientlibs/js/ff-playground-app/.gitignore
@@ -0,0 +1,43 @@
+# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
+
+# Compiled output
+/dist
+/tmp
+/out-tsc
+/bazel-out
+
+# Node
+/node_modules
+npm-debug.log
+yarn-error.log
+
+# IDEs and editors
+.idea/
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+
+# Visual Studio Code
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+.history/*
+
+# Miscellaneous
+/.angular/cache
+.sass-cache/
+/connect.lock
+/coverage
+/libpeerconnection.log
+testem.log
+/typings
+__screenshots__/
+
+# System files
+.DS_Store
+Thumbs.db
diff --git a/clientlibs/js/ff-playground-app/README.md b/clientlibs/js/ff-playground-app/README.md
new file mode 100644
index 0000000000..c1690a5cf8
--- /dev/null
+++ b/clientlibs/js/ff-playground-app/README.md
@@ -0,0 +1,61 @@
+# Feature Flag Playground
+
+A minimal Angular app for trying out the recipes in
+[`../docs/feature-flags-guide.md`](../docs/feature-flags-guide.md) against a real, running UpGrade backend.
+
+There are only two source files:
+
+- `src/app/feature-flag-playground.service.ts` — **the file to edit.** All `upgrade_client_lib`
+ usage lives here. `login()` currently uses the recommended ephemeral-mode recipe for the square,
+ and an ad-hoc `groupsets` override for the circles. Alternate recipes (stored mode, merged mode)
+ are included as commented-out blocks — swap them in to see how behavior changes.
+- `src/app/app.ts` — a single barebones component: a login form (user id + repeatable
+ group-type/values rows) and, once logged in, the two flag displays described below.
+
+## Prerequisites
+
+1. The UpGrade backend running locally at `http://localhost:3030` (`docker-compose up` from the
+ repo root, or however you normally run it). CORS already allows any `localhost` port, so the
+ Angular dev server's port doesn't matter.
+2. Two feature flags created via the admin UI, both under context **`upgrade-internal`**:
+ - `SQUARE_COLOR_FLAG_FOR_USER` — drives the square. Include/exclude it by whatever
+ class/school/district/instructor groups you plan to log in with.
+ - `SCHOOL_CIRCLE_FLAG` — drives the per-schoolId circles. Since each circle is checked as its
+ own ephemeral groupset containing only that one schoolId, scope this flag's inclusion by
+ `schoolId` if you want some circles green and others red.
+
+ Until these exist, everything will just show as "off" (red) — that's expected, not a bug.
+
+## Running it
+
+```bash
+yarn install # first time only; also re-run this after rebuilding the SDK (see below)
+yarn start # ng serve, on http://localhost:4300 (4200 is reserved for the UpGrade admin UI)
+```
+
+## Using it
+
+1. Enter a user id, or leave it blank to have one generated.
+2. Add zero or more group rows — pick a type (`classId` / `schoolId` / `districtId` /
+ `instructorId`) and type comma-separated values.
+3. Click **Log in**. This does not call `init()` — it configures an ephemeral-mode
+ `UpgradeClient` with exactly the groups you entered, and fetches both flags.
+4. The square is colored by `SQUARE_COLOR_FLAG_FOR_USER` for the whole set of groups you entered.
+ The circles row shows one circle per `schoolId` you entered, each independently colored by
+ `SCHOOL_CIRCLE_FLAG` for that one schoolId. If you didn't enter any schoolIds, it says so
+ instead of showing an empty row.
+5. **Log out** and repeat with different groups/ids to compare.
+
+## Picking up SDK changes
+
+This app depends on `upgrade_client_lib` via a local `file:` dependency pointing at
+`../packages/full-browser` (which `npm run build` in `clientlibs/js` populates from `../dist/browser`).
+After changing the client library source:
+
+```bash
+cd ../ && npm run build # rebuilds clientlibs/js, including packages/full-browser
+cd ff-playground-app && yarn install --offline # re-copies the local dependency
+```
+
+`yarn start` needs to be restarted afterward to pick up the change (Angular's dev server does not
+watch outside `src/`).
diff --git a/clientlibs/js/ff-playground-app/angular.json b/clientlibs/js/ff-playground-app/angular.json
new file mode 100644
index 0000000000..e833d0c5dd
--- /dev/null
+++ b/clientlibs/js/ff-playground-app/angular.json
@@ -0,0 +1,109 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "cli": {
+ "packageManager": "yarn"
+ },
+ "newProjectRoot": "projects",
+ "projects": {
+ "ff-playground-app": {
+ "projectType": "application",
+ "schematics": {
+ "@schematics/angular:component": {
+ "inlineTemplate": true,
+ "inlineStyle": true,
+ "skipTests": true
+ },
+ "@schematics/angular:class": {
+ "skipTests": true
+ },
+ "@schematics/angular:directive": {
+ "skipTests": true
+ },
+ "@schematics/angular:guard": {
+ "skipTests": true
+ },
+ "@schematics/angular:interceptor": {
+ "skipTests": true
+ },
+ "@schematics/angular:pipe": {
+ "skipTests": true
+ },
+ "@schematics/angular:resolver": {
+ "skipTests": true
+ },
+ "@schematics/angular:service": {
+ "skipTests": true
+ }
+ },
+ "root": "",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@angular/build:application",
+ "options": {
+ "browser": "src/main.ts",
+ "polyfills": [
+ "zone.js"
+ ],
+ "tsConfig": "tsconfig.app.json",
+ "assets": [
+ {
+ "glob": "**/*",
+ "input": "public"
+ }
+ ],
+ "styles": [
+ "src/styles.css"
+ ],
+ "allowedCommonJsDependencies": [
+ "upgrade_client_lib"
+ ]
+ },
+ "configurations": {
+ "production": {
+ "budgets": [
+ {
+ "type": "initial",
+ "maximumWarning": "750kB",
+ "maximumError": "1.5MB"
+ },
+ {
+ "type": "anyComponentStyle",
+ "maximumWarning": "4kB",
+ "maximumError": "8kB"
+ }
+ ],
+ "outputHashing": "all"
+ },
+ "development": {
+ "optimization": false,
+ "extractLicenses": false,
+ "sourceMap": true
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "serve": {
+ "builder": "@angular/build:dev-server",
+ "options": {
+ "port": 4300
+ },
+ "configurations": {
+ "production": {
+ "buildTarget": "ff-playground-app:build:production"
+ },
+ "development": {
+ "buildTarget": "ff-playground-app:build:development"
+ }
+ },
+ "defaultConfiguration": "development"
+ },
+ "extract-i18n": {
+ "builder": "@angular/build:extract-i18n"
+ }
+ }
+ }
+ }
+}
diff --git a/clientlibs/js/ff-playground-app/package.json b/clientlibs/js/ff-playground-app/package.json
new file mode 100644
index 0000000000..240299d574
--- /dev/null
+++ b/clientlibs/js/ff-playground-app/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "ff-playground-app",
+ "version": "0.0.0",
+ "scripts": {
+ "ng": "ng",
+ "start": "ng serve",
+ "build": "ng build",
+ "watch": "ng build --watch --configuration development"
+ },
+ "prettier": {
+ "printWidth": 100,
+ "singleQuote": true,
+ "overrides": [
+ {
+ "files": "*.html",
+ "options": {
+ "parser": "angular"
+ }
+ }
+ ]
+ },
+ "private": true,
+ "dependencies": {
+ "@angular/common": "^20.3.0",
+ "@angular/compiler": "^20.3.0",
+ "@angular/core": "^20.3.0",
+ "@angular/platform-browser": "^20.3.0",
+ "rxjs": "~7.8.0",
+ "tslib": "^2.3.0",
+ "upgrade_client_lib": "file:../packages/full-browser",
+ "zone.js": "~0.15.0"
+ },
+ "devDependencies": {
+ "@angular/build": "^20.3.32",
+ "@angular/cli": "^20.3.32",
+ "@angular/compiler-cli": "^20.3.0",
+ "typescript": "~5.9.2"
+ }
+}
\ No newline at end of file
diff --git a/clientlibs/js/ff-playground-app/public/favicon.ico b/clientlibs/js/ff-playground-app/public/favicon.ico
new file mode 100644
index 0000000000..57614f9c96
Binary files /dev/null and b/clientlibs/js/ff-playground-app/public/favicon.ico differ
diff --git a/clientlibs/js/ff-playground-app/src/app/app.config.ts b/clientlibs/js/ff-playground-app/src/app/app.config.ts
new file mode 100644
index 0000000000..b956a9e707
--- /dev/null
+++ b/clientlibs/js/ff-playground-app/src/app/app.config.ts
@@ -0,0 +1,9 @@
+import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
+
+export const appConfig: ApplicationConfig = {
+ providers: [
+ provideBrowserGlobalErrorListeners(),
+ provideZoneChangeDetection({ eventCoalescing: true }),
+
+ ]
+};
diff --git a/clientlibs/js/ff-playground-app/src/app/app.ts b/clientlibs/js/ff-playground-app/src/app/app.ts
new file mode 100644
index 0000000000..2a75bc3047
--- /dev/null
+++ b/clientlibs/js/ff-playground-app/src/app/app.ts
@@ -0,0 +1,290 @@
+import { Component, computed, effect, signal } from '@angular/core';
+import { FeatureFlagPlaygroundService, GroupType, SCHOOL_CIRCLE_FLAG, SQUARE_COLOR_FLAG } from './feature-flag-playground.service';
+
+interface GroupRow {
+ type: GroupType;
+ valuesText: string;
+}
+
+interface StoredForm {
+ userId: string;
+ rows: GroupRow[];
+}
+
+const GROUP_TYPES: GroupType[] = ['classId', 'schoolId', 'districtId', 'instructorId'];
+const FORM_STORAGE_KEY = 'ff-playground-form';
+
+// Remembers the last-entered userId + group rows across refreshes, purely as a form-fill
+// convenience — it does not restore the logged-in session itself.
+function loadStoredForm(): StoredForm {
+ try {
+ const raw = localStorage.getItem(FORM_STORAGE_KEY);
+ const parsed = raw ? JSON.parse(raw) : null;
+ return {
+ userId: typeof parsed?.userId === 'string' ? parsed.userId : '',
+ rows: Array.isArray(parsed?.rows) ? parsed.rows : [],
+ };
+ } catch {
+ return { userId: '', rows: [] };
+ }
+}
+
+function generateUUID(): string {
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID();
+ }
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+ const r = (Math.random() * 16) | 0;
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
+ return v.toString(16);
+ });
+}
+
+@Component({
+ selector: 'app-root',
+ imports: [],
+ template: `
+
+
Feature Flag Playground
+
+ Exercises the recipes from feature-flags-guide.md against a real UpGrade
+ backend. See feature-flag-playground.service.ts to change which recipe runs.
+
+
+ @if (!ff.loggedIn()) {
+
+
Log in
+
+
+
+
Groups
+ @for (row of rows(); track $index) {
+
+
+
+
+
+ }
+
+
+
+
+
+
+ @if (ff.error()) {
+
{{ ff.error() }}
+ }
+
+ } @else {
+
+
Session
+
userId: {{ ff.userId() }}
+
useGroups:{{ groupsJson() }}
+
+
+
+
+
{{ squareFlagKey }}
+
+ {{ ff.squareEnabled() ? 'ON' : 'OFF' }}
+
+
+
+
+
{{ circleFlagKey }} (by schoolId)
+ @if (ff.schoolIds().length === 0) {
+
User has no schoolIds associated.
+ } @else {
+
+ @for (schoolId of ff.schoolIds(); track schoolId) {
+