managarten/packages/shared-privacy/src/VisibilityPicker.svelte
Till JS 49935c9628 feat(shared-privacy): M1 — visibility foundation package
Scaffold the unified visibility/privacy layer introduced by docs/plans/
visibility-system.md. No module adopts it yet — this is the foundation
PR (M1). Module rollout lands in follow-ups starting with Library (M2).

What ships:
- @mana/shared-privacy package
  - VisibilityLevel enum ('private' | 'space' | 'unlisted' | 'public')
  - VisibilityLevelSchema + UnlistedTokenSchema (zod)
  - defaultVisibilityFor(spaceType): personal → private, else → space
  - predicates: canEmbedOnWebsite, isReachableByLink,
    isVisibleToSpaceMember, canAiAccessCrossUser (always false in P1)
  - generateUnlistedToken() — 32-char base64url, CSPRNG, ~192 bits
  - VISIBILITY_METADATA: German labels + descriptions + phosphor icon
    names so non-UI surfaces (audit logs, CLI) label levels consistently
  - <VisibilityPicker> svelte component: compact lock/globe trigger with
    4-option menu, full descriptions, optional compact + disabledLevels
- VisibilityChangedPayload type for the domain-event catalog (consumer
  registers it when the first module adopts the system)
- .claude/guidelines/visibility.md — step-by-step for module authors
  (schema migrations + store wiring + picker placement + embed resolver +
  legacy isPublic migration), with a pre-PR checklist
- Plan-doc "Offene Fragen" section rewritten as "Designentscheidungen"
  with the seven resolutions the user approved
- CLAUDE.md: shared-privacy listed in the packages table; visibility.md
  listed in the guidelines table
- 15 unit tests covering predicates (one-and-only-one 'public' for
  embed; phase-1 AI always-deny), defaults (personal vs multi-member,
  null fallback), token uniqueness + schema round-trip

Key constraints honored:
- `visibility` stays plaintext (NOT added to the encryption registry)
  so RLS predicates and publish resolvers can read it without the user's
  master key
- Publish flow remains "decrypt client-side, inline plaintext into
  snapshot" — the pattern picture.board already uses in embeds.ts
- Deny-by-default everywhere (personal default = private; unknown space
  type defaults to private; cross-user AI always false)

Not in this PR (per plan):
- No schema migrations in any module (M2–M6)
- No RLS predicate updates (arrives with M2)
- No /settings/privacy overview (M7)
- No unlisted share routes (M8)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:59:11 +02:00

218 lines
5.1 KiB
Svelte

<!--
VisibilityPicker — one compact dropdown used in every module's detail
view to toggle a record's visibility. The trigger is a lock/globe icon
with the short label; opening it reveals all four levels with their
descriptions.
Stateless — the consumer owns the current level and the onChange
callback. Keeps the component reusable across stores and encryption
boundaries.
-->
<script lang="ts">
import { Lock, UsersThree, LinkSimple, Globe, CaretDown } from '@mana/shared-icons';
import type { Component } from 'svelte';
import { VISIBILITY_LEVELS, VISIBILITY_METADATA, type VisibilityLevel } from './types';
interface Props {
level: VisibilityLevel;
onChange: (next: VisibilityLevel) => void;
/** Hide specific levels — e.g. a single-user space has no `space` option. */
disabledLevels?: VisibilityLevel[];
/** Show only the icon, not the label. For tight layouts. */
compact?: boolean;
/** Disable the whole control. */
disabled?: boolean;
}
let { level, onChange, disabledLevels = [], compact = false, disabled = false }: Props = $props();
const ICON_MAP: Record<VisibilityLevel, Component> = {
private: Lock,
space: UsersThree,
unlisted: LinkSimple,
public: Globe,
};
let open = $state(false);
let triggerEl = $state<HTMLButtonElement | null>(null);
const current = $derived(VISIBILITY_METADATA[level]);
const CurrentIcon = $derived(ICON_MAP[level]);
function toggle() {
if (disabled) return;
open = !open;
}
function select(next: VisibilityLevel) {
open = false;
if (next === level) return;
onChange(next);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && open) {
e.preventDefault();
open = false;
triggerEl?.focus();
}
}
</script>
<div class="vp" onkeydown={onKeyDown} role="presentation">
<button
bind:this={triggerEl}
class="vp__trigger"
class:vp__trigger--compact={compact}
class:vp__trigger--open={open}
onclick={toggle}
{disabled}
aria-haspopup="menu"
aria-expanded={open}
title={current.description}
>
<CurrentIcon size={14} weight="bold" />
{#if !compact}
<span class="vp__label">{current.label}</span>
<span class="vp__caret"><CaretDown size={10} weight="bold" /></span>
{/if}
</button>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="vp__backdrop" onclick={() => (open = false)}></div>
<div class="vp__menu" role="menu">
{#each VISIBILITY_LEVELS as lvl (lvl)}
{@const meta = VISIBILITY_METADATA[lvl]}
{@const Icon = ICON_MAP[lvl]}
{@const isDisabled = disabledLevels.includes(lvl)}
<button
class="vp__opt"
class:vp__opt--active={lvl === level}
disabled={isDisabled}
role="menuitemradio"
aria-checked={lvl === level}
onclick={() => select(lvl)}
>
<span class="vp__opt-icon"><Icon size={16} weight="bold" /></span>
<span class="vp__opt-text">
<span class="vp__opt-label">{meta.label}</span>
<span class="vp__opt-desc">{meta.description}</span>
</span>
</button>
{/each}
</div>
{/if}
</div>
<style>
.vp {
position: relative;
display: inline-flex;
}
.vp__trigger {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.55rem;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.375rem;
color: inherit;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s,
opacity 0.15s;
}
.vp__trigger:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(255, 255, 255, 0.22);
}
.vp__trigger--open {
background: rgba(99, 102, 241, 0.12);
border-color: rgba(99, 102, 241, 0.4);
}
.vp__trigger:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.vp__trigger--compact {
padding: 0.25rem 0.35rem;
}
.vp__label {
line-height: 1;
}
.vp__caret {
display: inline-flex;
opacity: 0.6;
}
.vp__backdrop {
position: fixed;
inset: 0;
z-index: 50;
background: transparent;
}
.vp__menu {
position: absolute;
top: calc(100% + 0.35rem);
right: 0;
z-index: 60;
display: flex;
flex-direction: column;
min-width: 16rem;
padding: 0.25rem;
border-radius: 0.5rem;
background: rgb(20, 24, 32);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
.vp__opt {
display: flex;
align-items: flex-start;
gap: 0.55rem;
padding: 0.5rem 0.625rem;
background: transparent;
border: none;
border-radius: 0.375rem;
color: inherit;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.vp__opt:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.04);
}
.vp__opt--active {
background: rgba(99, 102, 241, 0.12);
}
.vp__opt:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.vp__opt-icon {
display: inline-flex;
align-items: center;
margin-top: 0.1rem;
flex: 0 0 auto;
}
.vp__opt-text {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.vp__opt-label {
font-size: 0.8125rem;
font-weight: 600;
}
.vp__opt-desc {
font-size: 0.7rem;
opacity: 0.6;
line-height: 1.3;
}
</style>