Developer Docs

Handle System & Sigil Taxonomy

The Mallnline Handle System formalizes entity identity into a pipe-sigil taxonomy โ€” the platform's equivalent of Reddit's r/subreddit, Twitter's @username, and Discord's #channel. The pipe | character is the Mallnline glyph, creating a visual "doorway" that fits the virtual mall metaphor.


The Sigil Taxonomy

Every addressable entity on Mallnline has a type-specific sigil prefix:

Sigil Entity Example Route
m| Malet m|luminara-crafts /luminara-crafts
@ Visitor (User) @meekdenzo /u/meekdenzo
o| Organization o|acme-corp /orgs/acme-corp
r| Malet Rep r|sarah-luminara Not routable
u| u-Product u|chat Not routable

Dual-Mode Resolution

Both the @ universal sigil and the pipe sigil resolve to the same entity. The @ form is for cross-platform sharing (paste in Slack, tweet about it). The pipe form is for Mallnline-native identity (in-app, community, uChat).

@meekdenzo       โ†’  mallnline.com/profile/meekdenzo    (universal)
@acme-corp       โ†’  mallnline.com/orgs/acme-corp       (universal)
m|luminara       โ†’  mallnline.com/luminara             (pipe-only)

The `` Component

<MallHandle> is the unified rendering component for all handle displays across the platform. It replaces ad-hoc handle CSS classes with a single, type-aware, theme-responsive component.

Props

Prop Type Default Description
type HandleType โ€” Entity type: 'malet', 'user', 'org', 'rep', 'product'
handle string โ€” The handle value (without sigil prefix)
linked boolean false Wraps in an <a> tag with auto-resolved route
size 'xs' | 'sm' | 'md' | 'lg' 'sm' Font size tier

Usage

<script lang="ts">
  import MallHandle from '$lib/components/MallHandle.svelte';
</script>

<!-- Basic display -->
<MallHandle type="malet" handle="luminara-crafts" />
<!-- Renders: m|luminara-crafts (with indigo sigil) -->

<!-- With link -->
<MallHandle type="malet" handle="luminara-crafts" linked />
<!-- Renders: <a href="/luminara-crafts">m|luminara-crafts</a> -->

<!-- User handle -->
<MallHandle type="user" handle="meekdenzo" size="md" />
<!-- Renders: @meekdenzo (with teal sigil) -->

<!-- Organization handle -->
<MallHandle type="org" handle="acme-corp" size="xs" />
<!-- Renders: o|acme-corp (with purple sigil) -->

Design Tokens

Each entity type has a dedicated sigil color CSS variable:

Token Default Used By
--mall-handle-malet #667eea (Indigo) m| sigil accent
--mall-handle-user #0d9488 (Teal) @ sigil accent
--mall-handle-org #7c3aed (Purple) o| sigil accent
--mall-handle-rep #d97706 (Amber) r| sigil accent
--mall-handle-product #059669 (Emerald) u| sigil accent

The component uses var(--mall-font-mono) for the monospace font stack, and adapts handle value color in dark mode via --mall-dark-secondary-text.

Size Tiers

Size Font Size Best For
xs 0.65rem Data tables, sidebar items, card footers
sm 0.75rem Inline text, list items (default)
md 0.875rem Headers, profile banners
lg 1rem Hero sections, about pages

Handle Utilities (`handleUtils.ts`)

Located at $lib/utils/handleUtils, this module provides the core functions the <MallHandle> component and future sigil-aware search depend on.

Types

type HandleType = 'malet' | 'user' | 'org' | 'rep' | 'product';

interface ParsedSigil {
  type: HandleType;
  handle: string;
}

`getSigilPrefix(type: HandleType): string`

Returns the canonical sigil prefix for a given entity type.

getSigilPrefix('malet')   // โ†’ 'm|'
getSigilPrefix('user')    // โ†’ '@'
getSigilPrefix('org')     // โ†’ 'o|'
getSigilPrefix('rep')     // โ†’ 'r|'
getSigilPrefix('product') // โ†’ 'u|'

`getHandleRoute(type: HandleType, handle: string): string | null`

Resolves the URL route for a handle. Returns null for non-routable types.

getHandleRoute('malet', 'luminara-crafts') // โ†’ '/luminara-crafts'
getHandleRoute('org', 'acme-corp')         // โ†’ '/orgs/acme-corp'
getHandleRoute('user', 'meekdenzo')        // โ†’ '/u/meekdenzo'
getHandleRoute('rep', 'sarah')             // โ†’ null

`normalizeSigil(input: string): string`

Auto-corrects mobile-unfriendly pipe alternatives to canonical form. Addresses the mobile keyboard problem โ€” the pipe | requires multi-tap on most mobile keyboards.

normalizeSigil('m/luminara-crafts')  // โ†’ 'm|luminara-crafts'
normalizeSigil('m:luminara-crafts')  // โ†’ 'm|luminara-crafts'
normalizeSigil('m.luminara-crafts')  // โ†’ 'm|luminara-crafts'
normalizeSigil('m|luminara-crafts')  // โ†’ 'm|luminara-crafts' (no-op)
normalizeSigil('@meekdenzo')         // โ†’ '@meekdenzo' (no pipe needed)
normalizeSigil('plain-text')         // โ†’ 'plain-text' (no sigil)

Accepted alternatives for |: /, :, .

False-Positive Guard

The normalizer validates the remainder after the 2-char sigil prefix. Handles are strictly [a-z0-9-], so any remainder containing ., @, /, :, or whitespace is clearly a URL/email/domain and is not normalized:

normalizeSigil('m.co.uk')         // โ†’ 'm.co.uk'         (domain โ€” contains '.')
normalizeSigil('m.co@gmail.com')  // โ†’ 'm.co@gmail.com'  (email โ€” contains '@')
normalizeSigil('m/path/to')       // โ†’ 'm/path/to'       (URL path โ€” contains '/')
normalizeSigil('m. foo')          // โ†’ 'm. foo'          (sentence โ€” whitespace)
normalizeSigil('m.')              // โ†’ 'm.'              (bare separator)

The guard is implemented via a NON_HANDLE_CHARS regex (/[.@/::&#92;s]/) that rejects normalization when the remainder contains characters invalid in handles.

`parseSigil(input: string): ParsedSigil | null`

Parses a raw sigil string into its type and handle components. Auto-normalizes mobile input before parsing.

parseSigil('m|luminara-crafts')  // โ†’ { type: 'malet', handle: 'luminara-crafts' }
parseSigil('@meekdenzo')         // โ†’ { type: 'user', handle: 'meekdenzo' }
parseSigil('o|acme-corp')        // โ†’ { type: 'org', handle: 'acme-corp' }
parseSigil('m/luminara')         // โ†’ { type: 'malet', handle: 'luminara' } (auto-normalized)
parseSigil('plain-text')         // โ†’ null
parseSigil('m|')                 // โ†’ null (sigil without handle)

Sigil-Aware Search Integration

The search bar (Island.svelte) and search-mini.svelte are both wired into the handle system:

Search Bar Normalization

search-mini.svelte imports normalizeSigil and applies it in real-time on handleInput(), auto-correcting mobile-friendly inputs before the query reaches the suggestion engine.

Sigil-Prefix Detection

Island.svelte uses parseSigil() to detect sigil prefixes in the search query. When detected:

  1. The query is split into type + handle via parseSigil()
  2. Content suggestions are fetched using only the handle portion (without the sigil)
  3. Results are filtered by entity type (MALET, ORGANIZATION, etc.)
  4. Product/service suggestions are suppressed (sigil mode focuses on entity search)
  5. The suggestion dropdown header changes from "Blogs & More" to the entity label ("Malets", "Organizations", etc.)
// Island.svelte โ€” sigil-aware search branching
const detected = parseSigil(normalized); // e.g. { type: 'malet', handle: 'lum' }
if (detected) {
  const allContent = await searchStore.getContentSuggestions(detected.handle);
  contentResults = allContent.filter(r => r.type === typeMap[detected.type]);
  suggestions = { suggestions: [], categories: [], items: [] }; // clear product suggestions
}

`` Component

A dismissable CTA banner that prompts authenticated users without a handle to claim one.

Props

None โ€” the component reads $currentUser and $isAuthenticated from the auth store internally.

Show Condition

$isAuthenticated && $currentUser && !$currentUser.handle && !dismissed

Dismissal Logic

  • Stored in localStorage key handle_claim_banner_dismissed with the current timestamp
  • TTL: 7 days โ€” after expiry, the banner resurfaces
  • Safe failure: if localStorage is unavailable, the banner simply shows every time

Design

  • Teal gradient accent (#0d9488 โ†’ #14b8a6) matching --mall-handle-user
  • CTA button links to /settings#section-profile
  • Slide-in animation on mount
  • Dark-mode aware, responsive (CTA goes full-width on mobile)

Usage

<script lang="ts">
  import HandleClaimBanner from '$lib/components/HandleClaimBanner.svelte';
</script>

<HandleClaimBanner />

Currently deployed on:

  • /lobby โ€” between the IntentBar and FollowingFeed
  • /profile โ€” above the profile card

UserMenu Handle Display

When an authenticated user has claimed a handle, their @handle is displayed in the UserMenu dropdown modal directly below the "Hi, {name}!" greeting.

The handle is rendered using <MallHandle type="user" size="sm" /> inside a teal-tinted pill (rgba(13, 148, 136, 0.08)).

{#if $currentUser.handle}
  <div class="user-handle-display" data-testid="user-menu-handle">
    <MallHandle type="user" handle={$currentUser.handle} size="sm" />
  </div>
{/if}

Handle Claim Lifecycle

Visitors can claim a unique @handle through the Settings profile section. The lifecycle involves:

  1. Unclaimed state โ€” Visitors see "No handle claimed" with a "Claim" CTA
  2. Availability check โ€” Debounced cross-namespace validation against both user and Malet handle namespaces (parallel GraphQL requests to checkUserHandleAvailability + checkHandleAvailability)
  3. Claim โ€” claimHandle mutation via the Nodes subgraph
  4. Cooldown โ€” 30-day change cooldown enforced by handleChangedAt timestamp
  5. Display โ€” Claimed handles render via <MallHandle type="user"> in the profile banner and settings

Handle Validation Rules

Rule Constraint
Characters Lowercase a-z, digits 0-9, hyphens -
Length 2โ€“30 characters
Format Must start and end with alphanumeric
Uniqueness Globally unique across both user and Malet namespaces
Cooldown 30 days between changes

GraphQL Operations

# Check availability (user namespace)
query CheckUserHandleAvailability($handle: String!) {
  checkUserHandleAvailability(handle: $handle)
}

# Check availability (malet namespace)
query CheckHandleAvailability($handle: String!) {
  checkHandleAvailability(handle: $handle)
}

# Claim or change handle
mutation ClaimHandle($input: UpdateProfileInput!) {
  updateProfile(input: $input) {
    handle
    handleChangedAt
  }
}

Malet Handle Assignment

Malets get their handles during creation in the Malet Creation Wizard. The IdentityForm step:

  1. Auto-generates a handle from the Malet name (kebab-case, max 30 chars)
  2. Displays the m| prefix badge using the --mall-handle-malet design token
  3. Checks availability via debounced checkHandleAvailability query
  4. Shows status with โœ“ (available), โœ— (taken), or โ‹ฏ (checking) indicators

The handle lives at mallnline.com/{maletHandle} โ€” no prefix in the URL, just the raw handle as a top-level route parameter.


Adoption Across the Platform

The <MallHandle> component is used in these surfaces:

Surface File Type Size
Malet Header [malet]/Header.svelte malet md
Malet About page [malet]/about/+page.svelte malet md
Lobby Floor cards lobby/Floor.svelte malet xs
Org Dashboard cards lobby/OrgDashboard.svelte malet xs
Section listing sections/[slug]/+page.svelte malet sm
Profile page (owned/fav) profile/+page.svelte malet sm
Profile page (user badge) profile/+page.svelte user md/sm
UserMenu dropdown UserMenu.svelte user sm
Settings banner settings/ProfileSection.svelte user xs/sm
Admin Malets table admin/+page.svelte malet xs
Admin Edit History admin/+page.svelte malet xs

Self-Healing Profile Sync

The Mallnline backend uses federated subgraph separation โ€” the auth service (Rust/Postgres) manages identity (id, email, credentials), while the nodes subgraph (NestJS/MongoDB) manages profile data (handle, displayName, preferences).

When a user registers, the auth service emits a user_created TCP event to nodes, which creates the initial profile document. However, this event is fire-and-forget โ€” if nodes is down or the event is dropped, the user has no profile document and features like handle claims silently fail.

How the frontend self-heals

The _enrichUserProfile() function in src/lib/services/auth.ts runs after every successful auth check:

1. REST GET /me  โ†’ { id, email, username }       โ† auth service (Rust/Postgres)
2. authStore.setUser(baseUser)                     โ† immediate, no handle data
3. GraphQL ME_QUERY โ†’ { handle, handleChangedAt }  โ† federated gateway โ†’ nodes
4a. Profile exists โ†’ merge into store              โœ… normal path
4b. Profile MISSING โ†’ ENSURE_PROFILE mutation      โœ… self-healing path
     โ””โ”€ nodes updateProfile auto-creates profile

Key implementation details

  • Detection: If all nodes-owned fields (handle, displayName, handleChangedAt) are undefined, the profile is assumed missing.
  • Auto-create: The ENSURE_PROFILE mutation sends { displayName: username || email_prefix } to trigger the nodes updateProfile find-or-create pattern.
  • Non-blocking: Runs in the background โ€” auth is not delayed by enrichment. Failures are silent.
  • Idempotent: If the profile already exists, updateProfile is a no-op update.

GraphQL operations

# Fired after every auth check to merge federated profile data
query Me {
  me { id email displayName handle handleChangedAt createdAt }
}

# Fired only when no nodes profile exists (self-healing)
mutation EnsureProfile($input: UserUpdate!) {
  updateProfile(input: $input) { id handle handleChangedAt displayName }
}

File Reference

File Purpose
src/lib/components/MallHandle.svelte Unified sigil handle renderer component
src/lib/components/HandleClaimBanner.svelte Dismissable "Claim your @handle" CTA banner
src/lib/utils/handleUtils.ts Core utilities: normalizeSigil, parseSigil, getSigilPrefix, getHandleRoute
src/lib/components/UserMenu.svelte User menu dropdown โ€” shows @handle below greeting
src/lib/components/create-malet/IdentityForm.svelte Malet handle assignment during creation
src/lib/components/settings/ProfileSection.svelte User handle claim/change UI
src/routes/search/search-mini.svelte Search bar โ€” applies normalizeSigil() on input
src/routes/lobby/Island.svelte Island search โ€” sigil-aware search branching via parseSigil()
src/lib/queries/auth.ts CHECK_USER_HANDLE_AVAILABILITY, CLAIM_HANDLE, ENSURE_PROFILE queries
src/lib/services/auth.ts _enrichUserProfile() โ€” self-healing federated profile sync
src/lib/queries/lobby.ts CHECK_HANDLE_AVAILABILITY query
tests/handleUtils.test.ts 42 unit tests covering all 4 utility functions + false-positive guards
tests/handleClaimBanner.test.ts 6 unit tests covering dismissal TTL logic