{"id":49074593,"url":"https://github.com/druwilding/plural-profiles","last_synced_at":"2026-04-20T09:08:53.460Z","repository":{"id":339854726,"uuid":"1163600406","full_name":"druwilding/plural-profiles","owner":"druwilding","description":"A web app for pluralfok to create and share multiple profiles","archived":false,"fork":false,"pushed_at":"2026-04-11T08:03:00.000Z","size":7321,"stargazers_count":4,"open_issues_count":14,"forks_count":0,"subscribers_count":2,"default_branch":"main","last_synced_at":"2026-04-11T09:32:00.004Z","etag":null,"topics":["plural","profiles"],"latest_commit_sha":null,"homepage":"https://pluralprofiles.com","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/druwilding.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-21T21:43:05.000Z","updated_at":"2026-04-11T08:03:09.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/druwilding/plural-profiles","commit_stats":null,"previous_names":["druewilding/plural-profiles","druwilding/plural-profiles"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/druwilding/plural-profiles","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/druwilding%2Fplural-profiles","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/druwilding%2Fplural-profiles/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/druwilding%2Fplural-profiles/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/druwilding%2Fplural-profiles/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/druwilding","download_url":"https://codeload.github.com/druwilding/plural-profiles/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/druwilding%2Fplural-profiles/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":32040403,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-20T00:18:06.643Z","status":"online","status_checked_at":"2026-04-20T02:00:06.527Z","response_time":94,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["plural","profiles"],"created_at":"2026-04-20T09:08:52.733Z","updated_at":"2026-04-20T09:08:53.443Z","avatar_url":"https://github.com/druwilding.png","language":"Ruby","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Plural Profiles\n\nA web app for pluralfolk to create and share multiple profiles. Each account can have any number of profiles (with a name, pronouns, description, and avatar) and organise them into groups. Profiles and groups get unique shareable URLs, so you can give someone a link to a specific profile or a group of profiles without exposing anything else about your account.\n\n## Features\n\n### Profiles \u0026 groups\n\n- **Multiple profiles per account** — each with a name, pronouns, description, avatar image (with alt text), and optional heart emojis\n- **Groups** — organise profiles into named groups with a description and avatar\n- **Group nesting** — groups can contain other groups, forming trees of arbitrary depth. Each item (group or profile) in the tree can be individually hidden from the parent group's shared view using a simple checkbox, with hiding cascading to all descendants\n- **Path-scoped visibility** — when the same group appears at multiple points in a tree (diamond pattern), visibility overrides are scoped to the specific traversal path. Hiding a profile via one path doesn't affect its visibility via another path within the same root group\n- **Deep inclusion overrides** — per-item hidden state is stored in `inclusion_overrides`, scoped to a root group and a full traversal path (array of group IDs from root to the target's container). This enables precise, context-dependent control without affecting the target's own view or any other parent's view\n- **Labels** — profiles and groups can be tagged with labels (stored as a jsonb array). Labels appear in the management UI and can be used to filter listings. They're also central to the duplication feature\n- **Group duplication** — a multi-step wizard that deep-copies an entire group tree. The wizard scans for conflicts (existing copies with the same labels), lets you choose to reuse or re-copy each item, previews the result, then executes — copying avatars, edges, and inclusion overrides with remapped paths\n- **Heart emojis** — profiles can display custom heart emojis (≈45 hearts like `aqua_heart`, `void_heart`, `dewdrop_heart`), and `:heart_name:` shortcodes in descriptions are rendered as inline images\n- **Description formatting** — descriptions support basic HTML (`\u003cb\u003e`, `\u003ci\u003e`, `\u003cu\u003e`, `\u003cs\u003e`, `\u003cdetails\u003e`, `\u003csummary\u003e`) and `||spoiler||` syntax for togglable hidden text\n- **Created-at backdating** — profiles and groups can have their creation date set to a past date\n\n### Sharing \u0026 privacy\n\n- **Shareable UUID URLs** — every profile and group gets a unique URL (e.g. `/profiles/:uuid`, `/groups/:uuid`). Currently these require sign-in to view\n- **UUID regeneration** — profiles and groups can regenerate their share URL at any time\n- **Privacy-conscious sharing** — visitors can only see what they're linked to; there's no way to browse from one profile to discover other profiles or groups\n- **Interactive group explorer** — shared group pages feature a tree sidebar that lazy-loads content panels via AJAX, with a flat no-JS fallback for progressive enhancement\n\n### Themes\n\n- **Custom themes** — each account can create themes with a full set of colour overrides (page, pane, buttons, inputs, flash messages) plus an optional background image\n- **Theme application** — individual profiles and groups can each have their own theme, or the account's active theme applies site-wide\n- **Shared themes** — admins can share themes so all users can browse and duplicate them\n- **Site default theme** — one shared theme can be designated as the site default (applied when no other theme is active)\n- **Theme import/export** — themes can be exported as JSON and imported by pasting JSON or legacy CSS `:root {}` blocks\n- **Override preference** — accounts can choose to always use their own theme on shared pages instead of the page's assigned theme\n- **Tag filtering** — themes can be tagged (e.g. `dark`, `light`, `warm-colours`, `high-contrast`) and filtered by tag in the theme browser\n- **Background images** — themes support a background image with configurable repeat, size, position, and attachment\n\n### Account \u0026 auth\n\n- **Email \u0026 password authentication** — sign up, sign in, sign out, password reset, and email verification (built on Rails 8's built-in authentication generator)\n- **Account name** — optional username (2–30 chars, lowercase letters/numbers/underscores/hyphens) displayed on the account page\n- **Account deactivation** — admin-only action that deactivates an account and terminates all its sessions\n- **Email change** — change email with verification sent to the new address plus notification to the old one; pending changes can be cancelled\n- **Invite-only registration** — new accounts require an invite code. Signed-in users can generate up to 10 unused invite codes by default (configurable via the `MAX_INVITE_CODES_PER_USER` environment variable) from their account page to share with people they trust. Each code is single-use and is marked as redeemed when the new account is created\n\n## Tech stack\n\n- **Ruby** 3.3.10\n- **Rails** 8.1.3\n- **PostgreSQL** 16\n- **Puma** web server\n- **HAML** templates (via `haml-rails`)\n- **Propshaft** asset pipeline\n- **Importmap** + **Hotwire** (Turbo \u0026 Stimulus)\n- **Active Storage** for file uploads (local dev, S3 in production)\n- **BCrypt** for password hashing\n\n## Data model\n\n```\nUser\n ├── has_many Profiles (name, pronouns, description, avatar, labels, heart_emojis, uuid, theme)\n ├── has_many Groups (name, description, avatar, labels, uuid, theme)\n ├── has_many Themes (name, colors, background_image, tags, shared, site_default)\n ├── has_many InviteCodes (codes this user generated)\n ├── has_many Sessions\n ├── belongs_to active_theme (Theme, optional)\n ├── username (optional account name)\n └── deactivated_at (admin-set deactivation timestamp)\n\nProfile ←→ Group (many-to-many through GroupProfile)\nGroup   ←→ Group (many-to-many through GroupGroup)\nGroup   → has_many InclusionOverrides (path-scoped per-item hidden state)\n\nProfile → copied_from (Profile, optional — tracks duplication lineage)\nGroup   → copied_from (Group, optional — tracks duplication lineage)\n\nInviteCode — belongs to the generating User; records redeemed_by (User) and redeemed_at once used\n```\n\nThe `GroupGroup` join table connects parent and child groups with no additional columns — it is a simple edge in the group tree.\n\nThe `InclusionOverride` table stores per-item hidden state scoped to a root group and traversal path:\n\n- `group_id` — the root group this override applies to\n- `path` (jsonb array) — ordered list of group IDs from root (exclusive) to the group containing the target (inclusive). Empty array `[]` means the target is directly on the root group\n- `target_type` — `\"Group\"` or `\"Profile\"`\n- `target_id` — ID of the hidden group or profile\n\nUnique constraint on `(group_id, path, target_type, target_id)` ensures each item can only be hidden once per path per root. Because `path` is an ordered array, the same item can be hidden along one traversal path but visible along another — even when the same `group_group` edge is involved (diamond pattern).\n\nThe `Theme` table stores per-user colour schemes with ≈30 CSS custom property overrides (grouped into base, buttons, forms, flash), optional background image (Active Storage), layout properties (`background_repeat`, `background_size`, `background_position`, `background_attachment`), tags, credit/attribution, and sharing flags.\n\nThis allows plural folk to model complex, Venn-diagram-style group arrangements where not every part of one group belongs inside another.\n\n## Getting started\n\n### Prerequisites\n\n- [RVM](https://rvm.io/) (or another Ruby version manager)\n- [PostgreSQL 16](https://www.postgresql.org/) — on macOS: `brew install postgresql@16`\n- Make sure the PostgreSQL binaries are on your PATH:\n  ```sh\n  export PATH=\"/opt/homebrew/opt/postgresql@16/bin:$PATH\"\n  ```\n- [libvips](https://www.libvips.org/) — required for Active Storage image variants (avatar resizing):\n  ```sh\n  brew install vips\n  ```\n\n### Setup\n\n```sh\n# Clone the repo\ngit clone git@github.com:druewilding/plural-profiles.git\ncd plural-profiles\n\n# Update RVM to the latest stable version (ensures current Homebrew formula compatibility)\nrvm get stable\n\n# Install Ruby 3.3.10 and use the project gemset\n# (RVM picks up .ruby-version and .ruby-gemset automatically)\nrvm install ruby-3.3.10\nrvm use ruby-3.3.10@plural-profiles --create\n\n# Install dependencies\nbundle install\n\n# Create and migrate the databases\nbin/rails db:create\nbin/rails db:migrate\n\n# Start the server\nbin/rails server\n```\n\nThe app will be available at [http://localhost:3000](http://localhost:3000).\n\n### Running tests\n\nUnit and integration tests:\n\n```sh\nbin/rails test\n```\n\nSystem tests (requires Chrome):\n\n```sh\nbin/rails test:system\n```\n\nBoth suites together:\n\n```sh\nbin/rails test \u0026\u0026 bin/rails test:system\n```\n\nBoth are run automatically on pull requests via GitHub Actions CI (the `test` and `system-test` jobs).\n\n### Test data\n\nFixtures live in `test/fixtures/`. Three users are defined:\n\n| Fixture | Email             | Admin | Purpose                                                    |\n| ------- | ----------------- | ----- | ---------------------------------------------------------- |\n| `one`   | one@example.com   | Yes   | Primary user for most existing tests                       |\n| `two`   | two@example.com   | No    | Secondary user (isolation tests, cross-account validation) |\n| `three` | three@example.com | Yes   | Checkbox-model visibility scenario with diamond paths      |\n\nAll fixture accounts share the password `Plur4l!Pr0files#2026`.\n\n#### User `one`\n\nTwo groups and three profiles:\n\n- **Friends** — contains Alice (themed with Dark Forest)\n- **Everyone** — contains Friends (via a nested group relationship)\n- Profile **Alice** (she/her), **Bob** (he/him), **Everyone Profile** (they/them)\n\nOwns themes: **Dark Forest** (dark, cool-colours), **Sunset** (light, warm-colours), **Ocean Shared** (shared), **Default Shared** (shared, site default)\n\n#### User `two`\n\nOne group, one profile, and one theme:\n\n- **Family** — contains Carol\n- Profile **Carol** (they/them)\n- Theme **Cerulean**\n\n#### User `three` — checkbox-model visibility scenario\n\nNine groups, eight profiles, and one shared theme (**Another Admin Shared**). The groups are arranged to test path-scoped visibility overrides (the checkbox model). The key feature is a **diamond path**: Prism Circle is reachable via two different routes within Alpha Clan, allowing the same item to be hidden along one path but visible along another.\n\n**Alpha Clan tree** (diamond-path test):\n\n```\nAlpha Clan  ← Grove (direct)\n  ├── Spectrum\n  │     └── Prism Circle  ← Ember, Stray\n  │           └── Rogue Pack  [HIDDEN at path spectrum→prism_circle]  ← Stray [HIDDEN at this path]\n  └── Echo Shard\n        └── Prism Circle  (same group, different path)\n              └── Rogue Pack  (visible here — no override for echo_shard path)  ← Stray (visible here)\n```\n\n`InclusionOverride` records:\n- Hide Rogue Pack at path `[spectrum, prism_circle]` → excluded from Alpha Clan via the Spectrum branch\n- Hide Stray at path `[spectrum, prism_circle, rogue_pack]` → excluded via Spectrum branch\n\nVia the Echo Shard branch, no overrides exist — Rogue Pack and Stray are both visible. Viewing Spectrum directly (as its own root) also shows everything, since overrides are scoped to Alpha Clan.\n\n**Castle Clan tree** (selective hiding via overrides):\n\n```\nCastle Clan  ← Shadow (direct)\n  ├── Flux\n  │     ├── Echo Shard  ← Mirage (visible)\n  │     └── Static Burst  [HIDDEN at path flux]  ← Spark (cascade-hidden)\n  │     ← Drift [HIDDEN at path flux], Ripple [HIDDEN at path flux]\n  └── Castle Flux\n```\n\n`InclusionOverride` records hide Static Burst, Drift, and Ripple at path `[flux]` within Castle Clan. This means:\n- Mirage (in Echo Shard) **appears** in Castle Clan\n- Drift and Ripple (direct Flux profiles) are **hidden** from Castle Clan\n- Spark (in Static Burst) is **cascade-hidden** from Castle Clan (parent group hidden)\n- Viewing Flux directly still shows everything\n\n#### Seeding the development database\n\nTo create the test scenario in your local development database:\n\n```sh\nbin/rails runner script/phase1_seed.rb\n```\n\nThe script is safe to re-run — it creates a new user every time with unique groups, profiles and relationships.\n\n### Linting\n\nThis project uses [RuboCop](https://rubocop.org/) with the [Rails Omakase](https://github.com/rails/rubocop-rails-omakase/) style guide:\n\n```sh\nbin/rubocop\n```\n\nAuto-fix issues:\n\n```sh\nbin/rubocop -a\n```\n\n## Routes overview\n\n| Path                           | Description                                      |\n| ------------------------------ | ------------------------------------------------ |\n| `/`                            | Home page (at-a-glance dashboard when signed in) |\n| `POST /session`                | Sign in                                          |\n| `DELETE /session`              | Sign out                                         |\n| `/registration/new`            | Sign up (invite code required)                   |\n| `/email_verification?token=…`  | Verify email address                             |\n| `/passwords/…`                 | Password reset flow                              |\n| `/our/account`                 | Account settings (name, email, password, prefs)  |\n| `POST /our/invite-codes`       | Generate a new invite code (auth required)       |\n| `/our/profiles`                | Manage your profiles (auth required)             |\n| `/our/groups`                  | Manage your groups (auth required)               |\n| `/our/groups/:id/duplicate`    | Duplicate a group tree (multi-step wizard)       |\n| `/our/themes`                  | Manage and browse themes (auth required)         |\n| `/profiles/:uuid`              | Shared profile page                              |\n| `/groups/:uuid`                | Shared group page (interactive tree explorer)    |\n| `/groups/:uuid/profiles/:uuid` | Shared profile viewed within a group             |\n| `/stats`                       | Shared aggregate stats page                      |\n\n## Project structure\n\n```\napp/\n├── controllers/\n│   ├── our/\n│   │   ├── account_controller.rb       # Account settings (name, email, password, prefs)\n│   │   ├── profiles_controller.rb      # CRUD for the signed-in user's profiles\n│   │   ├── groups_controller.rb        # CRUD + manage members + duplication wizard\n│   │   ├── invite_codes_controller.rb  # Invite code generation and deletion\n│   │   └── themes_controller.rb        # Theme CRUD, activate, share, import/export\n│   ├── profiles_controller.rb          # Shared profile page\n│   ├── groups_controller.rb            # Shared group page + panel (AJAX tree content)\n│   ├── group_profiles_controller.rb    # Shared profile-within-group page + panel\n│   ├── stats_controller.rb            # Shared stats page\n│   ├── registrations_controller.rb     # Sign up (validates invite code)\n│   └── email_verifications_controller.rb\n├── models/\n│   ├── user.rb\n│   ├── profile.rb           # includes HasAvatar, HasLabels\n│   ├── group.rb             # includes HasAvatar, HasLabels; recursive CTE methods\n│   ├── group_group.rb\n│   ├── group_profile.rb\n│   ├── inclusion_override.rb\n│   ├── invite_code.rb\n│   └── theme.rb             # colour properties, background image, sharing, tags\n├── javascript/controllers/\n│   ├── clipboard_controller.js          # Copy-to-clipboard with feedback\n│   ├── details_persist_controller.js    # Persist \u003cdetails\u003e open/closed in localStorage\n│   ├── duplicate_resolution_controller.js  # Duplication conflict form validation\n│   ├── heart_picker_controller.js       # Progressive enhancement for heart emoji picker\n│   ├── spoiler_controller.js            # Toggle ||spoiler|| text visibility\n│   ├── theme_designer_controller.js     # Live theme preview, colour sync, JSON export\n│   ├── theme_import_controller.js       # Import JSON or CSS :root {} blocks\n│   ├── tree_controller.js              # Shared group tree explorer with lazy-loaded panels\n│   └── visibility_toggle_controller.js  # Async toggle for inclusion overrides\n├── views/\n│   ├── our/profiles/    # Profile management views (HAML)\n│   ├── our/groups/      # Group management + duplication wizard views (HAML)\n│   ├── our/themes/      # Theme management + designer views (HAML)\n│   ├── our/account/     # Account settings views (HAML)\n│   ├── profiles/        # Shared profile view\n│   ├── groups/          # Shared group view + tree explorer\n│   └── group_profiles/  # Shared profile-in-group view\n└── assets/\n    └── stylesheets/\n        └── application.css   # Hand-written CSS with custom colour palette\n```\n\n## Deployment (Scalingo)\n\n### Prerequisites\n\n- [Scalingo CLI](https://doc.scalingo.com/cli) installed\n- A Scalingo account\n\n### Create the app\n\n```sh\nscalingo create plural-profiles\n```\n\n### Add PostgreSQL\n\n```sh\nscalingo --app plural-profiles addons-add postgresql postgresql-starter-512\n```\n\nThis automatically sets the `DATABASE_URL` environment variable.\n\n### Set environment variables\n\n```sh\nscalingo --app plural-profiles env-set \\\n  SECRET_KEY_BASE=\"$(bin/rails secret)\" \\\n  APP_HOST=\"plural-profiles.osc-fr1.scalingo.io\" \\\n  ACTIVE_STORAGE_SERVICE=\"scalingo\"\n```\n\nFor avatar uploads, you'll need S3-compatible storage (AWS S3, Scalingo Object Storage, etc.):\n\n```sh\nscalingo --app plural-profiles env-set \\\n  S3_ACCESS_KEY_ID=\"your-key\" \\\n  S3_SECRET_ACCESS_KEY=\"your-secret\" \\\n  S3_BUCKET=\"your-bucket\" \\\n  S3_REGION=\"eu-west-1\" \\\n  S3_ENDPOINT=\"https://s3.eu-west-1.amazonaws.com\"\n```\n\nIf you don't set up S3 yet, avatars will use local disk storage (which is **ephemeral** on Scalingo — files are lost on redeploy).\n\n### Deploy\n\n```sh\ngit push scalingo main\n```\n\nThe `Procfile` runs `db:migrate` automatically after each deploy via the post-deployment hook.\n\n### Configuration files\n\n| File          | Purpose                                                |\n| ------------- | ------------------------------------------------------ |\n| `Procfile`    | Defines the web process and post-deploy migration hook |\n| `.buildpacks` | Uses APT + Ruby buildpacks (APT installs libvips)      |\n| `Aptfile`     | Lists APT packages to install (`libvips-dev`)          |\n\n## Licence\n\nAll rights reserved.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdruwilding%2Fplural-profiles","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdruwilding%2Fplural-profiles","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdruwilding%2Fplural-profiles/lists"}