diff --git a/Makefile b/Makefile index 9f2fae3..0b6656e 100644 --- a/Makefile +++ b/Makefile @@ -31,6 +31,10 @@ examples-upload: examples-build: cd $(BITEXT) && npm run build +.PHONY: dev +dev: + cd bitext && npm run dev + .PHONY: agentsmd agentsmd: - curl -fsSL https://raw.githubusercontent.com/dani-polani/agents-init/main/install.sh | sh + curl -fsSL https://raw.githubusercontent.com/dani-polani/agents-init/main/install.sh | sh \ No newline at end of file diff --git a/bitext/src/app.css b/bitext/src/app.css index d43f4d8..d3aa592 100644 --- a/bitext/src/app.css +++ b/bitext/src/app.css @@ -344,6 +344,23 @@ html { pointer-events: none; } +/* Pinned-group color badges: a top layer (above words and the connector layer, which Bauhaus + drops below the words) so the badges stay visible and clickable under every style. */ +.preview-badge-layer { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 3; + overflow: visible; +} + +.preview-badge-layer .pin-badge { + pointer-events: auto; + cursor: pointer; +} + path.link-path { pointer-events: none; transition: stroke-width 0.12s ease; diff --git a/bitext/src/lib/api/align.test.ts b/bitext/src/lib/api/align.test.ts index 4f63a07..5f14e3b 100644 --- a/bitext/src/lib/api/align.test.ts +++ b/bitext/src/lib/api/align.test.ts @@ -179,8 +179,8 @@ describe('buildAlignUrl', () => { }); it('keeps periods inside gloss tokens when tokenSplitChars omits the dot', () => { - // With default ".-|", "1SG.NOM" would split into two tokens. With "-|" it stays one, - // so word index 1 of the gloss line is "go.PST.IPFV" and the connection resolves. + // If "." were a separator, "1SG.NOM" would split into two tokens. With "-|" the dot is + // not a separator, so it stays one and word index 1 is "go.PST.IPFV" — the connection resolves. const result = buildAlignUrl(ORIGIN, { lines: ['1SG.NOM go.PST.IPFV', 'Я ходил'], alignments: [ @@ -197,16 +197,14 @@ describe('buildAlignUrl', () => { expect(c?.upperTokenId).toBe('l0-1'); }); - it('rejects gloss word index that only resolves under the default split chars', () => { - // Under default ".-|", "1SG.NOM PST.IPFV" has 4 tokens, so word 3 exists. - // We do NOT pass tokenSplitChars here, proving the dot still splits by default. + it('rejects a gloss word index that only existed under the old dot-splitting default', () => { + // The default is now "|"; the dot no longer splits. "1SG.NOM PST.IPFV" has 2 tokens, + // so word index 3 (which existed only when "." split by default) is out of range. const result = buildAlignUrl(ORIGIN, { lines: ['Я ходил', '1SG.NOM PST.IPFV'], alignments: [[0, 1, 1, 3]] }); - if (!('url' in result)) throw new Error('expected url'); - const state = decodeState(new URL(result.url).searchParams.get('data')); - expect(state.project.connections[0]!.lowerTokenId).toBe('l1-3'); // "IPFV" + expect('err' in result).toBe(true); }); it('rejects invalid tokenMergeChar (more than one character)', () => { diff --git a/bitext/src/lib/api/align.ts b/bitext/src/lib/api/align.ts index cd1cbd3..daf1828 100644 --- a/bitext/src/lib/api/align.ts +++ b/bitext/src/lib/api/align.ts @@ -39,7 +39,7 @@ export interface LineInput { /** Global visual settings overrides. All fields optional; unset fields inherit defaults. */ export interface SettingsInput { /** Color palette for connection lines. */ - palette?: 'pastel' | 'vivid' | 'academic'; + palette?: 'pastel' | 'vivid'; /** Connection line shape. */ lineStyle?: 'straight' | 'curved'; /** Connection line thickness (1–8). */ diff --git a/bitext/src/lib/components/editor-shell/EditorPanels.svelte b/bitext/src/lib/components/editor-shell/EditorPanels.svelte new file mode 100644 index 0000000..3e6fde8 --- /dev/null +++ b/bitext/src/lib/components/editor-shell/EditorPanels.svelte @@ -0,0 +1,14 @@ + + +{#if editorShellStore.tab === 'text'} + +{:else if editorShellStore.tab === 'style'} + +{:else} + +{/if} diff --git a/bitext/src/lib/components/editor-shell/EditorTabBar.svelte b/bitext/src/lib/components/editor-shell/EditorTabBar.svelte new file mode 100644 index 0000000..70fe68f --- /dev/null +++ b/bitext/src/lib/components/editor-shell/EditorTabBar.svelte @@ -0,0 +1,99 @@ + + +{#if variant === 'rail'} +
+ {#each tabs as t (t.tab)} + {@const active = isActive(t.tab)} + + {/each} +
+{:else} + +{/if} diff --git a/bitext/src/lib/components/editor/LineCard.svelte b/bitext/src/lib/components/editor/LineCard.svelte index a611dc4..7ccbd03 100644 --- a/bitext/src/lib/components/editor/LineCard.svelte +++ b/bitext/src/lib/components/editor/LineCard.svelte @@ -2,6 +2,7 @@ import { TrashBinOutline } from 'flowbite-svelte-icons'; import type { LineV2 } from '$lib/serialization/schema.js'; import { projectStore } from '$lib/state/project.svelte.js'; + import { lineDrag } from '$lib/state/lineDrag.svelte.js'; import { ButtonGroup, Input, InputAddon } from 'flowbite-svelte'; const addonClass = 'border-gray-300! bg-gray-50! px-2! dark:border-gray-600! dark:bg-gray-700!'; @@ -14,21 +15,52 @@ index: number; } = $props(); + const isDragging = $derived(lineDrag.draggingId === line.id); + const draggingIndex = $derived( + lineDrag.draggingId ? projectStore.lines.findIndex((l) => l.id === lineDrag.draggingId) : -1 + ); + const isDropTarget = $derived( + lineDrag.draggingId != null && lineDrag.draggingId !== line.id && lineDrag.overIndex === index + ); + // A drop line above / below this row, hidden for the two gaps that would leave the row in place. + const showDropBefore = $derived( + isDropTarget && lineDrag.overPos === 'before' && index !== draggingIndex + 1 + ); + const showDropAfter = $derived( + isDropTarget && lineDrag.overPos === 'after' && index !== draggingIndex - 1 + ); + + function dropPosition(e: DragEvent): 'before' | 'after' { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'; + } + function onDragStart(e: DragEvent) { e.dataTransfer?.setData('text/plain', line.id); e.dataTransfer!.effectAllowed = 'move'; + lineDrag.start(line.id); } function onDragOver(e: DragEvent) { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + if (lineDrag.draggingId && lineDrag.draggingId !== line.id) { + lineDrag.over(index, dropPosition(e)); + } } function onDrop(e: DragEvent) { e.preventDefault(); const draggedId = e.dataTransfer?.getData('text/plain'); + const pos = dropPosition(e); + lineDrag.end(); if (!draggedId || draggedId === line.id) return; - projectStore.moveLineToIndex(draggedId, index); + const dragIndex = projectStore.lines.findIndex((l) => l.id === draggedId); + if (dragIndex < 0) return; + // Position in the full list where it should land, then adjust for its own removal. + const insertBefore = pos === 'before' ? index : index + 1; + const restIndex = insertBefore > dragIndex ? insertBefore - 1 : insertBefore; + projectStore.moveLineToIndex(draggedId, restIndex); } function toggleLineDir() { @@ -56,18 +88,33 @@
+ {#if showDropBefore} + + {/if} + {#if showDropAfter} + + {/if}
diff --git a/bitext/src/lib/components/editor/LineEditModal.svelte b/bitext/src/lib/components/editor/LineEditModal.svelte index 1eaf065..a7b1603 100644 --- a/bitext/src/lib/components/editor/LineEditModal.svelte +++ b/bitext/src/lib/components/editor/LineEditModal.svelte @@ -8,6 +8,8 @@ import { selectionStore } from '$lib/state/selection.svelte.js'; import { layoutExportStore } from '$lib/state/layoutExport.svelte.js'; import { editorUiStore } from '$lib/state/editorUi.svelte.js'; + import { editorShellStore } from '$lib/state/editorShell.svelte.js'; + import { viewportStore } from '$lib/state/viewport.svelte.js'; import { linkHover } from '$lib/state/linkHover.svelte.js'; import { connectionForId, primaryConnectionForToken } from '$lib/domain/alignment.js'; import TokenChip from './TokenChip.svelte'; @@ -84,6 +86,13 @@ modalOpen = false; queueMicrotask(() => layoutExportStore.requestRemeasure()); } + + function openTokenizationSettings() { + done(); + editorShellStore.selectTab('text'); + if (viewportStore.isNarrow) editorShellStore.sheetOpen = true; + settingsNavStore.focusTokensTab(); + } settingsNavStore.focusTokensTab()} + title="Edit word splitting (Text → Word splitting)" + aria-label="Edit word splitting rules" + onclick={openTokenizationSettings} >
diff --git a/bitext/src/lib/components/preview/AlignmentSvg.svelte b/bitext/src/lib/components/preview/AlignmentSvg.svelte index 88f242e..70bc919 100644 --- a/bitext/src/lib/components/preview/AlignmentSvg.svelte +++ b/bitext/src/lib/components/preview/AlignmentSvg.svelte @@ -9,7 +9,8 @@ tokenLineId } from '$lib/domain/lines-helpers.js'; import { linkEndpoints, linkPathD, ribbonPathD } from '$lib/domain/link-geometry.js'; - import { connectorColor, getStyle } from '$lib/domain/styles.js'; + import { connectedConnectionComponents, connectedConnectionIds } from '$lib/domain/link-graph.js'; + import { connectorColor, getStyle, readableTextOn } from '$lib/domain/styles.js'; import { projectStore } from '$lib/state/project.svelte.js'; import { settingsStore } from '$lib/state/settings.svelte.js'; import { linkHover } from '$lib/state/linkHover.svelte.js'; @@ -21,7 +22,8 @@ connections, writesExportLayout = true, thicknessScale = 1, - zoom = 1 + zoom = 1, + showPins = false }: { rootEl: HTMLElement | null; connections: Connection[]; @@ -31,6 +33,8 @@ thicknessScale?: number; /** Visual pan/zoom scale of the wrapper; measurements are divided by it to stay in layout space. */ zoom?: number; + /** Editing affordance only: mark pinned-color groups. Never part of the export. */ + showPins?: boolean; } = $props(); let displayTokenLayout = $state>({}); @@ -41,6 +45,42 @@ /** Opacity multiplier for connectors not usable while picking the second token (0 = hidden is wrong; use low alpha). */ const PENDING_DIM_FACTOR = 0.22; + /** + * A palette badge sits above the top token of every pinned group so it is clear the color is + * fixed. The currently selected group is skipped — the color popover already covers it there. + */ + const pinnedBadges = $derived.by(() => { + const pend = selectionStore.pending; + const selectedComponent = pend ? connectedConnectionIds(connections, [pend.tokenId]) : null; + const out: { cx: number; y: number; color: string; tokenId: string; lineId: string }[] = []; + for (const component of connectedConnectionComponents(connections)) { + const groupConns = connections.filter((c) => component.has(c.id)); + if (!groupConns.some((c) => c.pinned)) continue; + if (selectedComponent && groupConns.some((c) => selectedComponent.has(c.id))) continue; + const color = groupConns.find((c) => c.color)?.color ?? '#94a3b8'; + let best: { cx: number; y: number; li: number; x: number; tid: string } | null = null; + for (const tid of groupConns.flatMap((c) => [c.upperTokenId, c.lowerTokenId])) { + const layout = displayTokenLayout[tid]; + if (!layout) continue; + const li = lineOrder.indexOf(tokenLineId(tid)); + if (li < 0) continue; + if (!best || li < best.li || (li === best.li && layout.x < best.x)) { + best = { cx: layout.cx, y: layout.y - 12, li, x: layout.x, tid }; + } + } + if (best) { + out.push({ + cx: best.cx, + y: best.y, + color, + tokenId: best.tid, + lineId: tokenLineId(best.tid) + }); + } + } + return out; + }); + function shouldDrawPath(conn: Connection): boolean { const lineOrder = projectStore.lines.map((l) => l.id); const pair = canonicalPair( @@ -281,3 +321,36 @@ {/if} {/each} + +{#if showPins} + + + {#each pinnedBadges as badge (badge.tokenId)} + {@const col = badge.color} + {@const fg = readableTextOn(col)} + selectionStore.selectToken(badge.lineId, badge.tokenId)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + selectionStore.selectToken(badge.lineId, badge.tokenId); + } + }} + > + + + + + + + + {/each} + +{/if} diff --git a/bitext/src/lib/components/preview/GroupColorPopover.svelte b/bitext/src/lib/components/preview/GroupColorPopover.svelte new file mode 100644 index 0000000..acd20c5 --- /dev/null +++ b/bitext/src/lib/components/preview/GroupColorPopover.svelte @@ -0,0 +1,162 @@ + + +{#if pending} + +{/if} diff --git a/bitext/src/lib/components/preview/StylePicker.svelte b/bitext/src/lib/components/preview/StylePicker.svelte index 22a6c49..b5819d8 100644 --- a/bitext/src/lib/components/preview/StylePicker.svelte +++ b/bitext/src/lib/components/preview/StylePicker.svelte @@ -1,17 +1,10 @@ + +{#if open} + +{/if} + + diff --git a/bitext/src/lib/components/settings/AppearanceTab.svelte b/bitext/src/lib/components/settings/AppearanceTab.svelte deleted file mode 100644 index 9ac6f63..0000000 --- a/bitext/src/lib/components/settings/AppearanceTab.svelte +++ /dev/null @@ -1,114 +0,0 @@ - - -
-
- - {#if s.autoFit} -
- - - settingsStore.patch({ - autoFitVariance: Number((e.currentTarget as HTMLInputElement).value) - })} - /> -

- 0% = every line uses one size · 100% = each line is sized on its own -

-
- {/if} -
-
- - - settingsStore.patch({ - lineThickness: Number((e.currentTarget as HTMLInputElement).value) - })} - /> -
-
- - - settingsStore.patch({ - lineOpacity: Number((e.currentTarget as HTMLInputElement).value) - })} - /> -
-
- - -
-
- - -
-
diff --git a/bitext/src/lib/components/settings/ColorsTab.svelte b/bitext/src/lib/components/settings/ColorsTab.svelte deleted file mode 100644 index d002658..0000000 --- a/bitext/src/lib/components/settings/ColorsTab.svelte +++ /dev/null @@ -1,100 +0,0 @@ - - -
-
-
-

- Match token color to links -

-

- Preview, editor chips, and exports when enabled -

-
- -
- {#if s.colorTokensByLink} -
-

Apply link color to

-
- - -
-
- {/if} -

- New links pick the next unused color from the palette. Changing the palette recolors every - existing link. -

-
- {#each names as name (name)} - - {/each} -
-
- {#each PALETTES[s.palette] as color, i (i)} - - {/each} -
-
diff --git a/bitext/src/lib/components/settings/FontsTab.svelte b/bitext/src/lib/components/settings/FontsTab.svelte index 395d6bf..845af1a 100644 --- a/bitext/src/lib/components/settings/FontsTab.svelte +++ b/bitext/src/lib/components/settings/FontsTab.svelte @@ -49,10 +49,7 @@
-

- Custom fonts library -

-

+

Upload font files once; they are stored in your browser. In the editor, set each line to Custom and pick the family name here.

diff --git a/bitext/src/lib/components/settings/LinguisticsTab.svelte b/bitext/src/lib/components/settings/LinguisticsTab.svelte deleted file mode 100644 index bd29197..0000000 --- a/bitext/src/lib/components/settings/LinguisticsTab.svelte +++ /dev/null @@ -1,154 +0,0 @@ - - -
-
-
-
-

- Token numbers -

- -
- -
-
- -
-

- Advanced tokenization -

- -
-
- - -
- updateTokenSplitChars((e.currentTarget as HTMLInputElement).value)} - /> -
- -
-
- - -
- updateTokenMergeChar((e.currentTarget as HTMLInputElement).value)} - /> -
- -
-
-

- Split punctuation -

- -
- -
- - {#if s.tokenSplitPunctuation} -
-
- - -
- updateTokenPunctuationChars((e.currentTarget as HTMLInputElement).value)} - /> -
- {/if} -
-
diff --git a/bitext/src/lib/components/settings/SegmentedControl.svelte b/bitext/src/lib/components/settings/SegmentedControl.svelte new file mode 100644 index 0000000..7bb676f --- /dev/null +++ b/bitext/src/lib/components/settings/SegmentedControl.svelte @@ -0,0 +1,34 @@ + + +
+ {#each options as opt (opt.value)} + + {/each} +
diff --git a/bitext/src/lib/components/settings/SettingsFieldHint.svelte b/bitext/src/lib/components/settings/SettingsFieldHint.svelte index d5d3493..09133af 100644 --- a/bitext/src/lib/components/settings/SettingsFieldHint.svelte +++ b/bitext/src/lib/components/settings/SettingsFieldHint.svelte @@ -1,20 +1,79 @@ - + - + {#if open} + + {text} + + {/if} diff --git a/bitext/src/lib/components/settings/SettingsPanel.svelte b/bitext/src/lib/components/settings/SettingsPanel.svelte deleted file mode 100644 index 4422343..0000000 --- a/bitext/src/lib/components/settings/SettingsPanel.svelte +++ /dev/null @@ -1,90 +0,0 @@ - - - -

- Settings -

- - - {#snippet titleSlot()} - - Style - - {/snippet} - - - - {#snippet titleSlot()} - - Colors - - {/snippet} - - - - {#snippet titleSlot()} - - Tokens - - {/snippet} - - - - {#snippet titleSlot()} - - Fonts - - {/snippet} - - - -
diff --git a/bitext/src/lib/components/settings/StyleThumb.svelte b/bitext/src/lib/components/settings/StyleThumb.svelte new file mode 100644 index 0000000..8e59ae7 --- /dev/null +++ b/bitext/src/lib/components/settings/StyleThumb.svelte @@ -0,0 +1,83 @@ + + + + + diff --git a/bitext/src/lib/components/settings/TokenizationSettings.svelte b/bitext/src/lib/components/settings/TokenizationSettings.svelte new file mode 100644 index 0000000..5a19992 --- /dev/null +++ b/bitext/src/lib/components/settings/TokenizationSettings.svelte @@ -0,0 +1,125 @@ + + +
+
+
+ + +
+ updateTokenSplitChars((e.currentTarget as HTMLInputElement).value)} + /> +
+ +
+
+ + +
+ updateTokenMergeChar((e.currentTarget as HTMLInputElement).value)} + /> +
+ +
+
+

+ Split punctuation +

+ +
+ +
+ + {#if s.tokenSplitPunctuation} +
+
+ + +
+ updateTokenPunctuationChars((e.currentTarget as HTMLInputElement).value)} + /> +
+ {/if} +
diff --git a/bitext/src/lib/components/share/CopyLinkButton.svelte b/bitext/src/lib/components/share/CopyLinkButton.svelte index 8d73ed1..d531feb 100644 --- a/bitext/src/lib/components/share/CopyLinkButton.svelte +++ b/bitext/src/lib/components/share/CopyLinkButton.svelte @@ -28,8 +28,8 @@ diff --git a/bitext/src/lib/components/share/ShareQuickRow.svelte b/bitext/src/lib/components/share/ShareQuickRow.svelte index 626397f..8835f6d 100644 --- a/bitext/src/lib/components/share/ShareQuickRow.svelte +++ b/bitext/src/lib/components/share/ShareQuickRow.svelte @@ -1,7 +1,7 @@ - -

Share

-

+

+

Copy a link with your alignment in the URL, or share to social media.

{#if browser} @@ -89,16 +88,14 @@
{/if} -
-
- - -
+
+ + {#if canWebShare} - + {/if}
- +
diff --git a/bitext/src/lib/components/shell/panels/ExportPanel.svelte b/bitext/src/lib/components/shell/panels/ExportPanel.svelte new file mode 100644 index 0000000..c0f8e74 --- /dev/null +++ b/bitext/src/lib/components/shell/panels/ExportPanel.svelte @@ -0,0 +1,19 @@ + + +
+
+

+ Download +

+ +
+
+

+ Share +

+ +
+
diff --git a/bitext/src/lib/components/shell/panels/StylePanel.svelte b/bitext/src/lib/components/shell/panels/StylePanel.svelte new file mode 100644 index 0000000..fac5583 --- /dev/null +++ b/bitext/src/lib/components/shell/panels/StylePanel.svelte @@ -0,0 +1,209 @@ + + +
+ + {#if isClassic} +
+

Canvas

+ settingsStore.patch({ background: v as 'light' | 'dark' })} + /> +
+ {/if} + + +
+

Colors

+
+ {#each PALETTE_NAMES as name (name)} + {@const selected = s.palette === name} + + {/each} +
+

+ New links pick the next unused color. Changing the palette recolors existing links. +

+ +
+ + {#if s.colorTokensByLink} + settingsStore.patch({ tokenLinkColorMode: v as 'text' | 'background' })} + /> + {/if} +
+
+ + +
+

Lines

+
+
+ + settingsStore.patch({ lineStyle: v as 'straight' | 'curved' })} + /> +
+
+ + + settingsStore.patch({ + lineThickness: Number((e.currentTarget as HTMLInputElement).value) + })} + /> +
+
+ + + settingsStore.patch({ + lineOpacity: Number((e.currentTarget as HTMLInputElement).value) + })} + /> +
+
+
+ + +
+

Text

+
+ + {#if s.autoFit} +
+ + + settingsStore.patch({ + autoFitVariance: Number((e.currentTarget as HTMLInputElement).value) + })} + /> +

+ 0% = every line uses one size · 100% = each line is sized on its own +

+
+ {/if} + +
+
+ + +
+

Custom fonts

+ +
+
diff --git a/bitext/src/lib/components/shell/panels/TextPanel.svelte b/bitext/src/lib/components/shell/panels/TextPanel.svelte new file mode 100644 index 0000000..1e403ab --- /dev/null +++ b/bitext/src/lib/components/shell/panels/TextPanel.svelte @@ -0,0 +1,73 @@ + + +
+ {#each projectStore.lines as line, i (line.id)} + + {/each} + +
+ + {#if projectStore.lines.length >= MAX_LINES} +

+ Soft limit: {MAX_LINES} lines — consider simplifying for shorter share links. +

+ {/if} +
+ + {#if settingsStore.settings.autoFit} +
+ + Long lines shrink to fit on one row automatically, so a diagram never wraps or breaks. +
+ {/if} + +
+

+ Word splitting +

+

+ How text turns into linkable words. Applies to every line. +

+ +
+
diff --git a/bitext/src/lib/domain/alignment.ts b/bitext/src/lib/domain/alignment.ts index 06adfe9..6b4332c 100644 --- a/bitext/src/lib/domain/alignment.ts +++ b/bitext/src/lib/domain/alignment.ts @@ -8,6 +8,8 @@ export interface Connection { upperTokenId: TokenId; lowerTokenId: TokenId; color?: string; + /** When true, this group keeps its color through palette/style changes until reset. */ + pinned?: boolean; } let connectionCounter = 0; diff --git a/bitext/src/lib/domain/link-graph.test.ts b/bitext/src/lib/domain/link-graph.test.ts new file mode 100644 index 0000000..7a23c9b --- /dev/null +++ b/bitext/src/lib/domain/link-graph.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { recolorUnpinnedComponents } from './link-graph.js'; +import type { Connection } from './alignment.js'; + +function conn( + id: string, + upper: string, + lower: string, + color: string, + pinned?: boolean +): Connection { + return { + id, + upperTokenId: upper, + lowerTokenId: lower, + color, + ...(pinned ? { pinned: true } : {}) + }; +} + +describe('recolorUnpinnedComponents', () => { + const palette = ['#aaa', '#bbb', '#ccc'] as const; + + it('recolors unpinned groups by cycling the palette in component order', () => { + const out = recolorUnpinnedComponents( + [conn('c1', 's-0', 't-0', '#old'), conn('c2', 's-1', 't-1', '#old')], + palette + ); + expect(out.map((c) => c.color)).toEqual(['#aaa', '#bbb']); + }); + + it('keeps a pinned group unchanged while recoloring the rest', () => { + const out = recolorUnpinnedComponents( + [ + conn('c1', 's-0', 't-0', '#pinned', true), + conn('c2', 's-1', 't-1', '#old'), + conn('c3', 's-2', 't-2', '#old') + ], + palette + ); + const byId = Object.fromEntries(out.map((c) => [c.id, c])); + expect(byId.c1.color).toBe('#pinned'); + expect(byId.c1.pinned).toBe(true); + // The pinned component is skipped, so unpinned ones still start from the palette's first color. + expect(byId.c2.color).toBe('#aaa'); + expect(byId.c3.color).toBe('#bbb'); + }); + + it('treats a multi-connection component as one group with one color', () => { + // s-0↔t-0 and t-0↔u-0 share t-0, so all three connections are one component. + const out = recolorUnpinnedComponents( + [ + conn('c1', 's-0', 't-0', '#old'), + conn('c2', 't-0', 'u-0', '#old'), + conn('c3', 's-1', 't-1', '#old') + ], + palette + ); + const byId = Object.fromEntries(out.map((c) => [c.id, c])); + expect(byId.c1.color).toBe(byId.c2.color); + expect(byId.c3.color).not.toBe(byId.c1.color); + }); +}); diff --git a/bitext/src/lib/domain/link-graph.ts b/bitext/src/lib/domain/link-graph.ts index d170bbe..20b5298 100644 --- a/bitext/src/lib/domain/link-graph.ts +++ b/bitext/src/lib/domain/link-graph.ts @@ -83,5 +83,27 @@ export function connectedConnectionComponents(connections: Connection[]): Set = {}; + let autoIndex = 0; + for (const component of connectedConnectionComponents(connections)) { + const anyPinned = connections.some((c) => component.has(c.id) && c.pinned); + if (anyPinned) continue; + const color = palette[autoIndex % palette.length]!; + autoIndex += 1; + component.forEach((id) => { + colorById[id] = color; + }); + } + return connections.map((c) => (colorById[c.id] ? { ...c, color: colorById[c.id] } : c)); +} + /** @deprecated */ export const connectedLinkComponents = connectedConnectionComponents; diff --git a/bitext/src/lib/domain/palettes.ts b/bitext/src/lib/domain/palettes.ts index 417448b..7099e26 100644 --- a/bitext/src/lib/domain/palettes.ts +++ b/bitext/src/lib/domain/palettes.ts @@ -1,7 +1,6 @@ export type PaletteName = | 'pastel' | 'vivid' - | 'academic' | 'neon' | 'sunset' | 'primary' @@ -16,16 +15,6 @@ export type PaletteName = export const PALETTES: Record = { pastel: ['#fda4af', '#93c5fd', '#86efac', '#fcd34d', '#d8b4fe', '#67e8f9', '#fca5a5', '#a5b4fc'], vivid: ['#ef4444', '#3b82f6', '#22c55e', '#eab308', '#a855f7', '#06b6d4', '#f97316', '#ec4899'], - academic: [ - '#64748b', - '#475569', - '#334155', - '#78716c', - '#57534e', - '#71717a', - '#52525b', - '#3f3f46' - ], // Style-matched palettes — also selectable on their own. neon: ['#ff5d8f', '#5cc8ff', '#b18cff', '#7af0c8', '#ffd86b', '#ff8a5c', '#6ee7ff', '#c77dff'], sunset: ['#ff3caa', '#22d3ee', '#f97316', '#a855f7', '#ff6ec7', '#38bdf8', '#fb7185', '#facc15'], @@ -33,10 +22,10 @@ export const PALETTES: Record = { jewel: ['#9c3b2e', '#2f5d8a', '#3f7a4f', '#9a7b2e', '#6b4d8a', '#2f8a8a', '#a85d3c', '#54616e'], ink: ['#2a2622', '#55504a', '#3a4a52', '#6a4a3a', '#454a3a', '#7a6a5a', '#3a3a4a', '#5a4a4a'], cyan: ['#cfe0ff', '#7ec8ff', '#a5f3ff', '#ffd27e', '#b9a5ff', '#7affd0', '#ff9ecf', '#dfe8f5'], - // Art-deco golds and jades (reads on the dark emerald canvas). - deco: ['#e9be57', '#9fd6ad', '#e8c98a', '#7fae86', '#d4b46a', '#bfe3c6', '#caa14e', '#a6cfae'], + // Art-deco jewels and gold (reads on the dark emerald canvas). + deco: ['#e9be57', '#5cc2a4', '#2f9fbf', '#e8825c', '#d1566e', '#8f7ad0', '#6f9fe0', '#b7cf63'], // Muted art-nouveau botanicals. - nouveau: ['#7f463e', '#496f3b', '#8a7a3a', '#6b4d6a', '#9a6b3c', '#4f6b5a', '#a15c4c', '#5f6e3a'], + nouveau: ['#7f463e', '#55793f', '#9a7d34', '#6b4d6a', '#b06a3c', '#4f6b5a', '#a7566a', '#4a6b8a'], // Risograph spot inks. riso: ['#df2321', '#006ac0', '#00a95c', '#ffb600', '#ff48b0', '#00b7c3', '#f15060', '#5a4fcf'], // Vivid rainbow so a set of links spans the spectrum. @@ -47,7 +36,6 @@ export const PALETTES: Record = { export const PALETTE_NAMES: PaletteName[] = [ 'pastel', 'vivid', - 'academic', 'neon', 'sunset', 'primary', diff --git a/bitext/src/lib/domain/styles.ts b/bitext/src/lib/domain/styles.ts index 05bde29..a0841a1 100644 --- a/bitext/src/lib/domain/styles.ts +++ b/bitext/src/lib/domain/styles.ts @@ -240,7 +240,7 @@ const STYLES: Record = { tintBaseHex: '#f3efdb' }, connector: { cap: 'round', mode: 'ribbon', taper: true, ribbonScale: 5, lineColor: '#515e30' }, - defaultFont: 'Spectral', + defaultFont: 'Amarante', palette: 'nouveau', frameClass: 'aligner-frame-nouveau' }, diff --git a/bitext/src/lib/examples/data/core.ts b/bitext/src/lib/examples/data/core.ts index 8296b10..c4cac04 100644 --- a/bitext/src/lib/examples/data/core.ts +++ b/bitext/src/lib/examples/data/core.ts @@ -57,6 +57,8 @@ export const CORE_EXAMPLES: ExampleEntry[] = [ noto('أنا أسكن في بيت كبير', 'ar', 'Noto Sans Arabic', 36, true), inter('I live in a big house', 'en', 30) ], + // The Hebrew prefix ב- ("in") is split from בית ("house") — needs hyphen as a separator. + settings: { tokenSplitChars: '-' }, connections: [ ['he-0', 'ar-0'], ['he-1', 'ar-1'], diff --git a/bitext/src/lib/examples/data/wikipedia.ts b/bitext/src/lib/examples/data/wikipedia.ts index cb168e8..4fa6562 100644 --- a/bitext/src/lib/examples/data/wikipedia.ts +++ b/bitext/src/lib/examples/data/wikipedia.ts @@ -62,9 +62,9 @@ export const WIKIPEDIA_EXAMPLES: ExampleEntry[] = [ ['src-0', 'tr-0'], ['src-0', 'tr-1'], ['src-2', 'tr-2'], - ['src-3', 'tr-3'], - ['src-4', 'tr-4'], - ['src-5', 'tr-5'] + ['src-2', 'tr-3'], + ['src-3', 'tr-4'], + ['src-4', 'tr-5'] ] }; })(), @@ -91,7 +91,7 @@ export const WIKIPEDIA_EXAMPLES: ExampleEntry[] = [ ['src-1', 'tr-3'], ['src-2', 'tr-4'], ['src-3', 'tr-5'], - ['src-4', 'tr-6'], + ['src-4', 'tr-7'], ['src-5', 'tr-8'] ] }; @@ -117,7 +117,7 @@ export const WIKIPEDIA_EXAMPLES: ExampleEntry[] = [ ['src-0', 'tr-0'], ['src-1', 'tr-1'], ['src-2', 'tr-2'], - ['src-3', 'tr-6'], + ['src-3', 'tr-7'], ['src-4', 'tr-6'], ['src-5', 'tr-3'], ['src-5', 'tr-4'] diff --git a/bitext/src/lib/fonts/google-fonts.ts b/bitext/src/lib/fonts/google-fonts.ts index d14c202..2d0219e 100644 --- a/bitext/src/lib/fonts/google-fonts.ts +++ b/bitext/src/lib/fonts/google-fonts.ts @@ -18,7 +18,8 @@ export const GOOGLE_FONT_OPTIONS: { family: string; label: string }[] = [ { family: 'Libre+Baskerville', label: 'Libre Baskerville' }, { family: 'Spectral', label: 'Spectral' }, { family: 'Sora', label: 'Sora' }, - { family: 'Poiret+One', label: 'Poiret One' } + { family: 'Poiret+One', label: 'Poiret One' }, + { family: 'Amarante', label: 'Amarante' } ]; export function googleFontStylesheetUrl(familyCss: string): string { diff --git a/bitext/src/lib/mcp/server.ts b/bitext/src/lib/mcp/server.ts index a85804f..f353bdf 100644 --- a/bitext/src/lib/mcp/server.ts +++ b/bitext/src/lib/mcp/server.ts @@ -106,7 +106,7 @@ const TOOL_INPUT_SCHEMA = { properties: { palette: { type: 'string', - enum: ['pastel', 'vivid', 'academic'], + enum: ['pastel', 'vivid'], description: 'Connection color palette. Default pastel.' }, lineStyle: { diff --git a/bitext/src/lib/seo/og-svg.ts b/bitext/src/lib/seo/og-svg.ts index 519d18f..847f46d 100644 --- a/bitext/src/lib/seo/og-svg.ts +++ b/bitext/src/lib/seo/og-svg.ts @@ -45,7 +45,7 @@ function fitTokens(tokens: Token[], budget: number): { tokens: Token[]; truncate * Assign a vivid-palette color to each connected component of links. * * We intentionally ignore the user's saved palette and their own colors on links: - * the OG preview must stay readable on a dark background (pastels wash out, academic + * the OG preview must stay readable on a dark background (pastels wash out, muted * greys vanish) and a user who painted everything black should still see a colorful * card. Components are iterated in link-array order so the mapping is deterministic * for a given `?data=` payload. diff --git a/bitext/src/lib/serialization/compact-v2.ts b/bitext/src/lib/serialization/compact-v2.ts index f5c15cc..ccab1dd 100644 --- a/bitext/src/lib/serialization/compact-v2.ts +++ b/bitext/src/lib/serialization/compact-v2.ts @@ -3,7 +3,7 @@ import { connectedConnectionIds } from '$lib/domain/link-graph.js'; import { pickUnusedPaletteColor, type PaletteName } from '$lib/domain/palettes.js'; import { tokenize, tokenizeOptionsFromVisualSettings } from '$lib/domain/tokens.js'; import { - DEFAULT_TOKEN_SPLIT_CHARS, + LEGACY_TOKEN_SPLIT_CHARS, defaultProjectSnapshot, defaultVisualSettings, normalizeVisualSettings, @@ -267,7 +267,8 @@ function compactToVisualSettings(s: CompactSettings | undefined): VisualSettings if (s.gff !== undefined) raw.glossFontFamily = String(s.gff); if (s.gfs !== undefined) raw.glossFontSource = Number(s.gfs) === 1 ? 'custom' : 'google'; if (s.gcn !== undefined) raw.glossCustomFontName = String(s.gcn); - if (s.sp !== undefined) raw.tokenSplitChars = String(s.sp); + // v2 predates the default-separator change; a missing `sp` meant the old `.-|` default. + raw.tokenSplitChars = s.sp !== undefined ? String(s.sp) : LEGACY_TOKEN_SPLIT_CHARS; if (s.bg !== undefined) { const n = Number(s.bg); /* Legacy 2 = image → light */ @@ -325,7 +326,7 @@ function compactToProject( } const sourceText = p.st ?? def.sourceText; const targetText = p.tt ?? def.targetText; - const splitChars = settings.tokenSplitChars ?? DEFAULT_TOKEN_SPLIT_CHARS; + const splitChars = settings.tokenSplitChars ?? LEGACY_TOKEN_SPLIT_CHARS; const tz = tokenizeOptionsFromVisualSettings({ tokenSplitChars: splitChars, tokenMergeChar: '', diff --git a/bitext/src/lib/serialization/compact-v3.ts b/bitext/src/lib/serialization/compact-v3.ts index 3e549fb..d56ab70 100644 --- a/bitext/src/lib/serialization/compact-v3.ts +++ b/bitext/src/lib/serialization/compact-v3.ts @@ -2,6 +2,7 @@ import { createConnectionId, type Connection } from '$lib/domain/alignment.js'; import { tokenize, tokenizeOptionsFromVisualSettings } from '$lib/domain/tokens.js'; import { SCHEMA_VERSION, + LEGACY_TOKEN_SPLIT_CHARS, clampLineGapPx, clampWordGapPx, DEFAULT_WORD_GAP_PX, @@ -78,8 +79,9 @@ function settingsToCompact(rounded: VisualSettingsV2): CompactSettings3 | undefi } function compactToVisualSettings(s: CompactSettings3 | undefined): VisualSettingsV2 { - if (!s) return defaultVisualSettingsV2(); - const raw: Record = {}; + // v3 predates the default-separator change; a missing `sp` meant the old `.-|` default. + if (!s) return { ...defaultVisualSettingsV2(), tokenSplitChars: LEGACY_TOKEN_SPLIT_CHARS }; + const raw: Record = { tokenSplitChars: LEGACY_TOKEN_SPLIT_CHARS }; if (s.lt !== undefined) raw.lineThickness = Number(s.lt); if (s.lo !== undefined) raw.lineOpacity = Number(s.lo); if (s.ls !== undefined) raw.lineStyle = Number(s.ls) === 0 ? 'straight' : 'curved'; diff --git a/bitext/src/lib/serialization/compact-v4.ts b/bitext/src/lib/serialization/compact-v4.ts new file mode 100644 index 0000000..3db73ff --- /dev/null +++ b/bitext/src/lib/serialization/compact-v4.ts @@ -0,0 +1,349 @@ +import { createConnectionId, type Connection } from '$lib/domain/alignment.js'; +import { tokenize, tokenizeOptionsFromVisualSettings } from '$lib/domain/tokens.js'; +import { + SCHEMA_VERSION, + clampLineGapPx, + clampWordGapPx, + DEFAULT_WORD_GAP_PX, + defaultProjectSnapshotV2, + defaultVisualSettingsV2, + normalizeProjectSnapshotV2, + normalizeVisualSettingsV2, + type AppStateV2, + type LinePairGapV2, + type LineV2, + type PairControlV2, + type ProjectSnapshotV2, + type VisualSettingsV2 +} from './schema.js'; + +export const COMPACT_SCHEMA_VERSION = 4 as const; + +type CompactSettings4 = Record; +type CompactProject4 = Record; + +export type CompactV4Wire = { + v: typeof COMPACT_SCHEMA_VERSION; + s?: CompactSettings4; + p?: CompactProject4; +}; + +function sortKeys>(obj: T): T { + const keys = Object.keys(obj).sort() as (keyof T)[]; + const out = {} as T; + for (const k of keys) { + out[k] = obj[k]; + } + return out; +} + +function roundVisualSettings(s: VisualSettingsV2): VisualSettingsV2 { + return { + ...s, + lineThickness: Math.round(s.lineThickness * 10) / 10, + lineOpacity: Math.round(s.lineOpacity * 100) / 100 + }; +} + +function settingsToCompact(rounded: VisualSettingsV2): CompactSettings4 | undefined { + const def = roundVisualSettings(defaultVisualSettingsV2()); + const o: CompactSettings4 = {}; + // Site chrome (`theme`) and preview toolbar visibility (`previewHideChrome`) are local UI only; + // they must not affect share URLs or decoded alignment payloads. + if (rounded.lineThickness !== def.lineThickness) o.lt = rounded.lineThickness; + if (rounded.lineOpacity !== def.lineOpacity) o.lo = rounded.lineOpacity; + if (rounded.lineStyle !== def.lineStyle) o.ls = rounded.lineStyle === 'straight' ? 0 : 1; + if (rounded.palette !== def.palette) o.pl = rounded.palette; + if (rounded.showNumbers !== def.showNumbers) o.sn = rounded.showNumbers ? 1 : 0; + if (rounded.colorTokensByLink !== def.colorTokensByLink) o.ct = rounded.colorTokensByLink ? 1 : 0; + if (rounded.tokenLinkColorMode !== def.tokenLinkColorMode) { + o.lb = rounded.tokenLinkColorMode === 'background' ? 1 : 0; + } + if (rounded.tokenSplitChars !== def.tokenSplitChars) o.sp = rounded.tokenSplitChars; + if (rounded.tokenSplitPunctuation !== def.tokenSplitPunctuation) { + o.pp = rounded.tokenSplitPunctuation ? 1 : 0; + } + if (rounded.tokenPunctuationChars) o.px = rounded.tokenPunctuationChars; + if (rounded.style !== def.style) o.st = rounded.style; + if (rounded.autoFit !== def.autoFit) o.af = rounded.autoFit ? 1 : 0; + if (rounded.autoFitVariance !== def.autoFitVariance) o.av = rounded.autoFitVariance; + if (rounded.background !== def.background) { + o.bg = rounded.background === 'dark' ? 1 : 0; + } + const keysBeforeFinalize = Object.keys(o).length; + if (keysBeforeFinalize > 0) { + o.mg = rounded.tokenMergeChar; + } + return keysBeforeFinalize > 0 ? sortKeys(o) : undefined; +} + +function compactToVisualSettings(s: CompactSettings4 | undefined): VisualSettingsV2 { + if (!s) return defaultVisualSettingsV2(); + const raw: Record = {}; + if (s.lt !== undefined) raw.lineThickness = Number(s.lt); + if (s.lo !== undefined) raw.lineOpacity = Number(s.lo); + if (s.ls !== undefined) raw.lineStyle = Number(s.ls) === 0 ? 'straight' : 'curved'; + if (s.pl !== undefined) raw.palette = String(s.pl); + if (s.sn !== undefined) raw.showNumbers = Number(s.sn) === 1; + if (s.ct !== undefined) raw.colorTokensByLink = Number(s.ct) === 1; + if (s.lb !== undefined) raw.tokenLinkColorMode = Number(s.lb) === 1 ? 'background' : 'text'; + if (s.sp !== undefined) raw.tokenSplitChars = String(s.sp); + if (s.mg !== undefined) raw.tokenMergeChar = String(s.mg); + if (s.px !== undefined) raw.tokenPunctuationChars = String(s.px); + if (s.pp !== undefined) raw.tokenSplitPunctuation = Number(s.pp) === 1; + if (s.bg !== undefined) { + const n = Number(s.bg); + /* Legacy 2 = image → light */ + raw.background = n === 1 ? 'dark' : 'light'; + } + if (s.st !== undefined) raw.style = String(s.st); + if (s.af !== undefined) raw.autoFit = Number(s.af) === 1; + if (s.av !== undefined) raw.autoFitVariance = Number(s.av); + return normalizeVisualSettingsV2(raw); +} + +function encodeLines(lines: LineV2[]): string { + return lines + .map((l) => { + const cols = [ + l.id, + encodeURIComponent(l.rawText), + l.font.family, + l.font.source === 'custom' ? 1 : 0, + l.font.customName ? encodeURIComponent(l.font.customName) : '', + String(l.textSizePx), + String(l.gapWordPx) + ]; + if (l.rtl) cols.push('1'); + return cols.join('\t'); + }) + .join('|'); +} + +function decodeLines(encoded: string): LineV2[] { + const out: LineV2[] = []; + for (const seg of encoded.split('|')) { + if (!seg) continue; + const parts = seg.split('\t'); + const id = parts[0]!; + const rawEnc = parts[1]!; + const family = parts[2]!; + const fs = parts[3]; + const cnEnc = parts[4]; + const sz = parts[5]; + const gwRaw = parts[6]; + const rtlRaw = parts[7]; + if (!id || !family) continue; + const customName = cnEnc ? decodeURIComponent(cnEnc) : undefined; + const parsedGw = gwRaw !== undefined && gwRaw !== '' ? Number(gwRaw) : undefined; + const gapWordPx = + parsedGw !== undefined && Number.isFinite(parsedGw) + ? clampWordGapPx(parsedGw) + : DEFAULT_WORD_GAP_PX; + const rtl = + rtlRaw !== undefined && rtlRaw !== '' && (Number(rtlRaw) === 1 || rtlRaw === 'true'); + out.push({ + id, + rawText: decodeURIComponent(rawEnc ?? ''), + font: { + family, + source: Number(fs) === 1 ? 'custom' : 'google', + ...(customName ? { customName } : {}) + }, + textSizePx: Math.max(12, Math.min(64, Number(sz) || 36)), + gapWordPx, + ...(rtl ? { rtl: true } : {}) + }); + } + return out; +} + +function encodeConnections(conns: Connection[]): string { + return conns + .map((c) => { + const hex = c.color?.replace(/^#/u, '').replace(/[^0-9a-fA-F]/gu, '') ?? ''; + // A pinned group keeps its color across palette changes; encoded as a trailing `p`. + // Pinning requires a color, so `hex` is always present when `pinned` is set. + if (hex.length >= 3) { + return c.pinned + ? `${c.upperTokenId},${c.lowerTokenId},${hex},p` + : `${c.upperTokenId},${c.lowerTokenId},${hex}`; + } + return `${c.upperTokenId},${c.lowerTokenId}`; + }) + .join(';'); +} + +function decodeConnections(s: string): Connection[] { + const out: Connection[] = []; + for (const seg of s.split(';')) { + if (!seg) continue; + const parts = seg.split(','); + const u = parts[0]; + const lo = parts[1]; + if (!u || !lo) continue; + const colorHex = parts[2]; + const color = colorHex && /^[0-9a-fA-F]{3,8}$/u.test(colorHex) ? `#${colorHex}` : undefined; + const pinned = parts[3] === 'p' && color != null; + out.push({ + id: createConnectionId(), + upperTokenId: u, + lowerTokenId: lo, + color, + ...(pinned ? { pinned: true } : {}) + }); + } + return out; +} + +function encodePairControls(pc: PairControlV2[]): string | undefined { + const hidden = pc.filter((p) => !p.showConnectors); + if (hidden.length === 0) return undefined; + /** `\t` between line ids, `|` between pairs — line ids may contain `-`. */ + return hidden.map((p) => `${p.upperLineId}\t${p.lowerLineId}`).join('|'); +} + +function decodePairControls(s: string | undefined): PairControlV2[] { + if (!s) return []; + const out: PairControlV2[] = []; + for (const seg of s.split('|')) { + if (!seg) continue; + const tab = seg.indexOf('\t'); + if (tab === -1) continue; + const upper = seg.slice(0, tab); + const lower = seg.slice(tab + 1); + if (upper && lower) out.push({ upperLineId: upper, lowerLineId: lower, showConnectors: false }); + } + return out; +} + +function encodeLinePairGaps(gaps: LinePairGapV2[]): string | undefined { + if (gaps.length === 0) return undefined; + return gaps.map((g) => `${g.upperLineId}\t${g.lowerLineId}\t${g.gapPx}`).join('|'); +} + +function decodeLinePairGaps(s: string | undefined): LinePairGapV2[] { + if (!s) return []; + const out: LinePairGapV2[] = []; + for (const seg of s.split('|')) { + if (!seg) continue; + const parts = seg.split('\t'); + if (parts.length < 3) continue; + const upper = parts[0]!; + const lower = parts[1]!; + const px = Number(parts[2]); + if (!upper || !lower || !Number.isFinite(px)) continue; + out.push({ upperLineId: upper, lowerLineId: lower, gapPx: clampLineGapPx(px) }); + } + return out; +} + +function lineEquals(a: LineV2 | undefined, b: LineV2 | undefined): boolean { + if (!a || !b) return false; + return ( + a.id === b.id && + a.rawText === b.rawText && + a.textSizePx === b.textSizePx && + a.gapWordPx === b.gapWordPx && + a.font.family === b.font.family && + a.font.source === b.font.source && + a.font.customName === b.font.customName && + (a.rtl ?? false) === (b.rtl ?? false) + ); +} + +function projectToCompact(project: ProjectSnapshotV2): CompactProject4 | undefined { + const def = defaultProjectSnapshotV2(); + const linesMatch = + project.lines.length === def.lines.length && + project.lines.every((l, i) => lineEquals(l, def.lines[i])); + const extrasEmpty = + project.connections.length === 0 && + project.pairControls.length === 0 && + project.linePairGaps.length === 0; + if (linesMatch && extrasEmpty) return undefined; + + const o: CompactProject4 = {}; + o.ln = encodeLines(project.lines); + if (project.connections.length) o.cn = encodeConnections(project.connections); + const pcEnc = encodePairControls(project.pairControls); + if (pcEnc) o.pc = pcEnc; + const pgEnc = encodeLinePairGaps(project.linePairGaps); + if (pgEnc) o.pg = pgEnc; + return sortKeys(o); +} + +function compactToProject(p: CompactProject4 | undefined): ProjectSnapshotV2 { + const def = defaultProjectSnapshotV2(); + if (!p?.ln) return { ...def }; + const lines = decodeLines(p.ln); + const connections = p.cn ? decodeConnections(p.cn) : []; + const pairControls = decodePairControls(p.pc); + const linePairGaps = decodeLinePairGaps(p.pg !== undefined ? String(p.pg) : undefined); + return { lines, pairControls, linePairGaps, connections }; +} + +/** Drop connections that are not between adjacent lines in stack order. */ +function pruneConnections( + project: ProjectSnapshotV2, + settings: VisualSettingsV2 +): ProjectSnapshotV2 { + const lineOrder = project.lines.map((l) => l.id); + const tokenToLine = new Map(); + const tz = tokenizeOptionsFromVisualSettings(settings); + for (const line of project.lines) { + for (const t of tokenize(line.rawText, line.id, tz)) { + tokenToLine.set(t.id, line.id); + } + } + const adj = new Set(); + for (let i = 0; i < lineOrder.length - 1; i++) { + adj.add(`${lineOrder[i]}\0${lineOrder[i + 1]!}`); + adj.add(`${lineOrder[i + 1]!}\0${lineOrder[i]!}`); + } + const tok = new Set(tokenToLine.keys()); + const connections = project.connections.filter((c) => { + if (!tok.has(c.upperTokenId) || !tok.has(c.lowerTokenId)) return false; + const lu = tokenToLine.get(c.upperTokenId)!; + const ll = tokenToLine.get(c.lowerTokenId)!; + return adj.has(`${lu}\0${ll}`); + }); + return { ...project, connections }; +} + +export function toCompactJSON(state: AppStateV2): string { + const slimSettings = { ...state.settings }; + const rounded = roundVisualSettings(slimSettings); + const sCompact = settingsToCompact(rounded); + const pCompact = projectToCompact(state.project); + const wire: CompactV4Wire = { v: COMPACT_SCHEMA_VERSION }; + if (sCompact) wire.s = sCompact; + if (pCompact) wire.p = pCompact; + return JSON.stringify(wire); +} + +export function fromCompactWire(wire: CompactV4Wire): AppStateV2 { + if (wire.v !== COMPACT_SCHEMA_VERSION) { + throw new Error(`compact: expected v:${COMPACT_SCHEMA_VERSION}`); + } + const settings = compactToVisualSettings(wire.s); + let project = compactToProject(wire.p); + /** Legacy compact stored a global word gap as `s.gw`; old `ln` rows had no per-line column. */ + const legacyGw = + wire.s?.gw !== undefined && Number.isFinite(Number(wire.s.gw)) + ? clampWordGapPx(Number(wire.s.gw)) + : undefined; + const legacyGl = + wire.s?.gl !== undefined && Number.isFinite(Number(wire.s.gl)) + ? clampLineGapPx(Number(wire.s.gl)) + : undefined; + if (legacyGw !== undefined) { + project = { + ...project, + lines: project.lines.map((l) => ({ ...l, gapWordPx: legacyGw })) + }; + } + project = normalizeProjectSnapshotV2(project, undefined, legacyGl); + project = pruneConnections(project, settings); + return { v: SCHEMA_VERSION, project, settings }; +} diff --git a/bitext/src/lib/serialization/decode.ts b/bitext/src/lib/serialization/decode.ts index a779027..caca082 100644 --- a/bitext/src/lib/serialization/decode.ts +++ b/bitext/src/lib/serialization/decode.ts @@ -9,8 +9,32 @@ import { fromCompactWire as fromCompactWireV3, type CompactV3Wire } from './compact-v3.js'; +import { + COMPACT_SCHEMA_VERSION as COMPACT_V4, + fromCompactWire as fromCompactWireV4, + type CompactV4Wire +} from './compact-v4.js'; import { appStateV2FromV1, migrate, type AppStateV2 } from './schema.js'; +function tryDecodeV4(data: string): AppStateV2 | null { + const json = inflateBase64url(data); + if (!json) return null; + let parsed: unknown; + try { + parsed = JSON.parse(json) as unknown; + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object') return null; + const o = parsed as { v?: unknown }; + if (o.v !== COMPACT_V4) return null; + try { + return fromCompactWireV4(parsed as CompactV4Wire); + } catch { + return null; + } +} + function tryDecodeV3(data: string): AppStateV2 | null { const json = inflateBase64url(data); if (!json) return null; @@ -53,6 +77,8 @@ export function decodeState(data: string | null | undefined): AppStateV2 { if (!data || typeof data !== 'string') { return migrate({}); } + const v4 = tryDecodeV4(data); + if (v4) return v4; const v3 = tryDecodeV3(data); if (v3) return v3; const v2 = tryDecodeV2(data); diff --git a/bitext/src/lib/serialization/encode.ts b/bitext/src/lib/serialization/encode.ts index 0516f9b..84911fb 100644 --- a/bitext/src/lib/serialization/encode.ts +++ b/bitext/src/lib/serialization/encode.ts @@ -1,6 +1,6 @@ import type { AppStateV2 } from './schema.js'; import { deflateBase64url } from './codec.js'; -import { toCompactJSON } from './compact-v3.js'; +import { toCompactJSON } from './compact-v4.js'; export function encodeState(state: AppStateV2): string { return deflateBase64url(toCompactJSON(state)); diff --git a/bitext/src/lib/serialization/schema.ts b/bitext/src/lib/serialization/schema.ts index 6313ff5..6f264ce 100644 --- a/bitext/src/lib/serialization/schema.ts +++ b/bitext/src/lib/serialization/schema.ts @@ -42,7 +42,13 @@ export const MAX_TEXT_SIZE_PX = 64; export const DEFAULT_WORD_GAP_PX = 14; export const MIN_WORD_GAP_PX = 0; export const MAX_WORD_GAP_PX = 56; -export const DEFAULT_TOKEN_SPLIT_CHARS = '.-|'; +export const DEFAULT_TOKEN_SPLIT_CHARS = '|'; +/** + * Default separators in effect for compact v2/v3 payloads. Dot and hyphen used to split by + * default; they were dropped because users did not expect them to. Legacy decoders pin this so + * old share links keep their original tokenization (see compact-v3 / compact-v2). + */ +export const LEGACY_TOKEN_SPLIT_CHARS = '.-|'; /** Default join character for new projects; omits from compact when equal. */ export const DEFAULT_TOKEN_MERGE_CHAR = '+'; @@ -483,7 +489,8 @@ export function normalizeProjectConnections(raw: unknown): Connection[] { id: o.id, upperTokenId: upper, lowerTokenId: lower, - color: typeof o.color === 'string' ? o.color : undefined + color: typeof o.color === 'string' ? o.color : undefined, + ...(o.pinned === true ? { pinned: true } : {}) }); continue; } diff --git a/bitext/src/lib/serialization/serialization-roundtrip.test.ts b/bitext/src/lib/serialization/serialization-roundtrip.test.ts index ffab251..5218bec 100644 --- a/bitext/src/lib/serialization/serialization-roundtrip.test.ts +++ b/bitext/src/lib/serialization/serialization-roundtrip.test.ts @@ -73,7 +73,7 @@ describe('visual settings migration (v1 wire normalizer)', () => { }); }); -describe('compact v3 encode/decode (current share format)', () => { +describe('compact v4 encode/decode (current share format)', () => { it('round-trip: defaults via migrate shape', () => { const s = migrate({}); const decoded = decodeState(encodeState(s)); @@ -313,3 +313,68 @@ describe('compact v3 encode/decode (current share format)', () => { expect(() => fromCompactWireV2({ v: 99 } as never)).toThrow(); }); }); + +describe('compact v4: pinned group color', () => { + function baseWithConnection(pinned: boolean, color: string): AppStateV2 { + const base = migrate({}); + return { + ...base, + project: { + ...base.project, + connections: [ + { + id: 'c1', + upperTokenId: 's-0', + lowerTokenId: 't-0', + color, + ...(pinned ? { pinned: true } : {}) + } + ] + } + }; + } + + it('round-trips a pinned connection with its color', () => { + const s = baseWithConnection(true, '#123456'); + const decoded = decodeState(encodeState(s)); + const c = decoded.project.connections[0]!; + expect(c.color).toBe('#123456'); + expect(c.pinned).toBe(true); + }); + + it('an unpinned connection stays unpinned through a round-trip', () => { + const s = baseWithConnection(false, '#123456'); + const decoded = decodeState(encodeState(s)); + expect(decoded.project.connections[0]!.pinned).toBeUndefined(); + }); +}); + +describe('default separators: dot and hyphen no longer split', () => { + it('a fresh project keeps "U.S.A" as a single token', () => { + const base = migrate({}); + const s: AppStateV2 = { + ...base, + project: { + ...base.project, + lines: base.project.lines.map((l, i) => (i === 0 ? { ...l, rawText: 'U.S.A foo-bar' } : l)) + } + }; + expect(s.settings.tokenSplitChars).toBe('|'); + // Round-trips without the dot/hyphen splitting the words apart. + const decoded = decodeState(encodeState(s)); + expect(decoded.project.lines[0]!.rawText).toBe('U.S.A foo-bar'); + expect(decoded.settings.tokenSplitChars).toBe('|'); + }); + + it('a legacy v3 share link keeps its original "." splitting', () => { + // v3 predates the change; a missing `sp` must decode to the old `.-|` default so the + // connection on the dot-split token (s-1 = "S") survives instead of being pruned. + const v3 = JSON.stringify({ + v: 3, + p: { ln: 's\tU.S.A\tInter\t0\t\t36\t14|t\tESSE\tInter\t0\t\t36\t14', cn: 's-1,t-0' } + }); + const decoded = decodeState(deflateBase64url(v3)); + expect(decoded.settings.tokenSplitChars).toBe('.-|'); + expect(decoded.project.connections).toHaveLength(1); + }); +}); diff --git a/bitext/src/lib/server/home-page-load.ts b/bitext/src/lib/server/home-page-load.ts new file mode 100644 index 0000000..dfef450 --- /dev/null +++ b/bitext/src/lib/server/home-page-load.ts @@ -0,0 +1,30 @@ +import { getHomePartnerOrder } from '$lib/partners/home-rotation.js'; +import { buildAppStateFromExample } from '$lib/examples/build-app-state.js'; +import { findGalleryBySlug } from '$lib/examples/catalog.js'; +import { findExample } from '$lib/state/examples.js'; +import { decodeState } from '$lib/serialization/decode.js'; + +/** Load for the editor home (`/`): decoded `?data=` / `?example=` state plus partner rotation. */ +export function loadHomePage(url: URL) { + const data = url.searchParams.get('data'); + const exampleParam = url.searchParams.get('example'); + + let initialState = data ? decodeState(data) : null; + let exampleInvalid = false; + + if (!initialState && exampleParam) { + const gallery = findGalleryBySlug(exampleParam); + if (gallery) { + initialState = buildAppStateFromExample(findExample(gallery.exampleId)); + } else { + exampleInvalid = true; + } + } + + return { + dataParam: data, + initialState, + exampleInvalid, + homePartnerOrder: getHomePartnerOrder(Date.now()) + }; +} diff --git a/bitext/src/lib/state/editorShell.svelte.ts b/bitext/src/lib/state/editorShell.svelte.ts new file mode 100644 index 0000000..31bf8e1 --- /dev/null +++ b/bitext/src/lib/state/editorShell.svelte.ts @@ -0,0 +1,36 @@ +/** + * State for the full-screen editor shell (`/editor`). + * + * Connecting words is always live on the canvas, so there is no "link mode". The shell only + * tracks which config panel is shown: Text (the lines), Style (look), or Export (download/share). + * On wide screens the panel lives in a docked rail; on narrow screens it is a bottom sheet that + * opens above a pinned bottom bar (`sheetOpen`). + */ +export type EditorTab = 'text' | 'style' | 'export'; + +class EditorShellStore { + tab = $state('text'); + /** Narrow-screen bottom sheet visibility (the rail is always visible on wide screens). */ + sheetOpen = $state(false); + + /** Bottom-bar tap: open the tab, or close the sheet if that tab is already open. */ + toggleTab(tab: EditorTab) { + if (this.sheetOpen && this.tab === tab) { + this.sheetOpen = false; + return; + } + this.tab = tab; + this.sheetOpen = true; + } + + /** Rail click (wide screens): just switch the panel. */ + selectTab(tab: EditorTab) { + this.tab = tab; + } + + closeSheet() { + this.sheetOpen = false; + } +} + +export const editorShellStore = new EditorShellStore(); diff --git a/bitext/src/lib/state/lineDrag.svelte.ts b/bitext/src/lib/state/lineDrag.svelte.ts new file mode 100644 index 0000000..b967b54 --- /dev/null +++ b/bitext/src/lib/state/lineDrag.svelte.ts @@ -0,0 +1,25 @@ +/** Shared drag state for reordering line rows, so every LineCard can show the same feedback. */ +class LineDragStore { + /** Id of the row currently being dragged (null when idle). */ + draggingId = $state(null); + /** Index of the row the pointer is over. */ + overIndex = $state(null); + /** Whether the drop lands above or below that row (chosen by pointer position within it). */ + overPos = $state<'before' | 'after'>('before'); + + start(id: string) { + this.draggingId = id; + } + + over(index: number, pos: 'before' | 'after') { + this.overIndex = index; + this.overPos = pos; + } + + end() { + this.draggingId = null; + this.overIndex = null; + } +} + +export const lineDrag = new LineDragStore(); diff --git a/bitext/src/lib/state/project-pinning.test.ts b/bitext/src/lib/state/project-pinning.test.ts new file mode 100644 index 0000000..5f8b137 --- /dev/null +++ b/bitext/src/lib/state/project-pinning.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { projectStore } from './project.svelte.js'; +import { settingsStore } from './settings.svelte.js'; +import type { LineV2 } from '$lib/serialization/schema.js'; + +function line(id: string, rawText: string): LineV2 { + return { + id, + rawText, + font: { family: 'Inter', source: 'google' }, + textSizePx: 36, + gapWordPx: 14 + }; +} + +function loadTwoLines() { + projectStore.loadSnapshotV2({ + lines: [line('s', 'a b'), line('t', 'c d')], + connections: [], + pairControls: [], + linePairGaps: [] + }); +} + +describe('project store: pinned group colors', () => { + beforeEach(() => { + settingsStore.reset(); + loadTwoLines(); + }); + + it('addConnection pins the group when given an explicit color', () => { + projectStore.addConnection('s-0', 't-0', 'pastel', '#123456'); + const c = projectStore.connections[0]!; + expect(c.color).toBe('#123456'); + expect(c.pinned).toBe(true); + expect(projectStore.isGroupPinned('s-0')).toBe(true); + }); + + it('a new link joining a pinned group inherits its pinned color', () => { + projectStore.addConnection('s-0', 't-0', 'pastel', '#123456'); + // s-1 → t-0 shares t-0 with the pinned component, so it should inherit the pin. + projectStore.addConnection('s-1', 't-0', 'pastel'); + expect(projectStore.connections.every((c) => c.color === '#123456' && c.pinned)).toBe(true); + }); + + it('recolorAllConnections keeps a pinned group and recolors the rest', () => { + projectStore.addConnection('s-0', 't-0', 'pastel', '#123456'); // pinned + projectStore.addConnection('s-1', 't-1', 'pastel'); // auto + projectStore.recolorAllConnections('vivid'); + const pinned = projectStore.connections.find((c) => c.upperTokenId === 's-0')!; + const auto = projectStore.connections.find((c) => c.upperTokenId === 's-1')!; + expect(pinned.color).toBe('#123456'); + expect(auto.color).not.toBe('#123456'); + }); + + it('unpinGroupColor clears the pin and recolors to an unused palette color', () => { + projectStore.addConnection('s-0', 't-0', 'pastel', '#123456'); + projectStore.unpinGroupColor('s-0', 'pastel'); + const c = projectStore.connections[0]!; + expect(c.pinned).toBe(false); + expect(c.color).not.toBe('#123456'); + expect(projectStore.isGroupPinned('s-0')).toBe(false); + expect(projectStore.hasGroup('s-0')).toBe(true); + }); +}); diff --git a/bitext/src/lib/state/project.svelte.ts b/bitext/src/lib/state/project.svelte.ts index e16a4fa..f56c953 100644 --- a/bitext/src/lib/state/project.svelte.ts +++ b/bitext/src/lib/state/project.svelte.ts @@ -4,7 +4,7 @@ import { removeConnection, type Connection } from '$lib/domain/alignment.js'; -import { connectedConnectionComponents, connectedConnectionIds } from '$lib/domain/link-graph.js'; +import { connectedConnectionIds, recolorUnpinnedComponents } from '$lib/domain/link-graph.js'; import { filterConnectionsByAdjacency, canonicalPair, @@ -14,7 +14,7 @@ import { adjacentLineKeys, isStackedAdjacentPair } from '$lib/domain/lines-helpers.js'; -import { PALETTES, type PaletteName } from '$lib/domain/palettes.js'; +import { PALETTES, pickUnusedPaletteColor, type PaletteName } from '$lib/domain/palettes.js'; import { tokenizeOptionsFromVisualSettings, type Token } from '$lib/domain/tokens.js'; import { clampLineGapPx, @@ -240,7 +240,13 @@ class ProjectStore { layoutExportStore.requestRemeasureAfterLayout(); } - addConnection(upperTokenId: string, lowerTokenId: string, palette: PaletteName) { + /** `pinnedColor` (from a group color pick) forces the new group's color and pins it. */ + addConnection( + upperTokenId: string, + lowerTokenId: string, + palette: PaletteName, + pinnedColor?: string + ) { const opts = this.currentTokenizeOptions(); const tokenToLine = lineOrderTokenIds(this.lines, opts); const lineIds = this.lines.map((l) => l.id); @@ -249,27 +255,76 @@ class ProjectStore { const ll = tokenToLine.get(lowerTokenId); if (lu == null || ll == null) return; if (!adj.has(`${lu}\0${ll}`)) return; - const color = pendingAlignmentColor(this.connections, [upperTokenId], [lowerTokenId], palette); const seedTokens = new Set([upperTokenId, lowerTokenId]); + // Inherit a pinned color when either endpoint's existing group is pinned. + const componentBefore = connectedConnectionIds(this.connections, seedTokens); + const inheritedPinned = this.connections.find( + (c) => componentBefore.has(c.id) && c.pinned && c.color + ); + let color: string; + let pinned: boolean; + if (pinnedColor) { + color = pinnedColor; + pinned = true; + } else if (inheritedPinned?.color) { + color = inheritedPinned.color; + pinned = true; + } else { + color = pendingAlignmentColor(this.connections, [upperTokenId], [lowerTokenId], palette); + pinned = false; + } const merged = addAtomicConnections(this.connections, [{ upperTokenId, lowerTokenId }], color); const componentAfter = connectedConnectionIds(merged, seedTokens); - this.connections = merged.map((c) => (componentAfter.has(c.id) ? { ...c, color } : c)); + this.connections = merged.map((c) => (componentAfter.has(c.id) ? { ...c, color, pinned } : c)); + } + + /** Pin a group (component containing `tokenId`) to a color; it survives palette/style changes. */ + pinGroupColor(tokenId: string, color: string) { + const component = connectedConnectionIds(this.connections, [tokenId]); + if (component.size === 0) return; + this.connections = this.connections.map((c) => + component.has(c.id) ? { ...c, color, pinned: true } : c + ); + } + + /** Unpin a group and recolor it with the next unused palette color (auto behavior). */ + unpinGroupColor(tokenId: string, palette: PaletteName) { + const component = connectedConnectionIds(this.connections, [tokenId]); + if (component.size === 0) return; + const otherColors = new Set( + this.connections + .filter((c) => !component.has(c.id)) + .map((c) => c.color) + .filter((c): c is string => Boolean(c)) + ); + const color = pickUnusedPaletteColor(palette, otherColors, this.connections.length); + this.connections = this.connections.map((c) => + component.has(c.id) ? { ...c, color, pinned: false } : c + ); + } + + /** True when `tokenId` is part of at least one connection. */ + hasGroup(tokenId: string): boolean { + return this.connections.some((c) => c.upperTokenId === tokenId || c.lowerTokenId === tokenId); + } + + /** True when `tokenId` belongs to a pinned group. */ + isGroupPinned(tokenId: string): boolean { + const component = connectedConnectionIds(this.connections, [tokenId]); + if (component.size === 0) return false; + return this.connections.some((c) => component.has(c.id) && c.pinned); + } + + /** The color of the group containing `tokenId`, if any. */ + groupColor(tokenId: string): string | null { + const c = this.connections.find( + (x) => x.upperTokenId === tokenId || x.lowerTokenId === tokenId + ); + return c?.color ?? null; } recolorAllConnections(palette: PaletteName) { - const pool = PALETTES[palette]; - const components = connectedConnectionComponents(this.connections); - const colorById: Record = {}; - components.forEach((component, i) => { - const color = pool[i % pool.length]!; - component.forEach((id) => { - colorById[id] = color; - }); - }); - this.connections = this.connections.map((c) => ({ - ...c, - color: colorById[c.id] ?? pool[0]! - })); + this.connections = recolorUnpinnedComponents(this.connections, PALETTES[palette]); } removeConnectionById(connectionId: string) { @@ -280,13 +335,6 @@ class ProjectStore { this.connections = []; } - updateConnectionColor(connectionId: string, color: string) { - const c = this.connections.find((x) => x.id === connectionId); - if (!c) return; - const component = connectedConnectionIds(this.connections, [c.upperTokenId, c.lowerTokenId]); - this.connections = this.connections.map((x) => (component.has(x.id) ? { ...x, color } : x)); - } - getSnapshot(): ProjectSnapshotV2 { return { lines: this.lines.map((l) => ({ ...l, font: { ...l.font } })), diff --git a/bitext/src/lib/state/selection.svelte.ts b/bitext/src/lib/state/selection.svelte.ts index b1046d9..ddf4bdc 100644 --- a/bitext/src/lib/state/selection.svelte.ts +++ b/bitext/src/lib/state/selection.svelte.ts @@ -5,6 +5,8 @@ class SelectionStore { pending: { lineId: string; tokenId: string } | null = $state(null); /** Shown when user clicks a non-adjacent line while a token is pending */ adjacencyHint = $state(false); + /** Color pre-picked for the selected (still unlinked) token; applied to the next connection. */ + pendingColor = $state(null); /** First click pins a token; second click on adjacent line creates a connection; same line changes pin. */ previewTokenClick(lineId: string, tokenId: string) { @@ -12,14 +14,16 @@ class SelectionStore { const cur = this.pending; if (!cur) { this.pending = { lineId, tokenId }; + this.pendingColor = null; return; } if (cur.lineId === lineId && cur.tokenId === tokenId) { - this.pending = null; + this.clear(); return; } if (cur.lineId === lineId) { this.pending = { lineId, tokenId }; + this.pendingColor = null; return; } const lineIds = projectStore.lines.map((l) => l.id); @@ -28,20 +32,53 @@ class SelectionStore { if (ia < 0 || ib < 0 || Math.abs(ia - ib) !== 1) { this.adjacencyHint = true; this.pending = { lineId, tokenId }; + this.pendingColor = null; return; } const palette = settingsStore.settings.palette; + const pinnedColor = this.pendingColor ?? undefined; if (ia < ib) { - projectStore.addConnection(cur.tokenId, tokenId, palette); + projectStore.addConnection(cur.tokenId, tokenId, palette, pinnedColor); } else { - projectStore.addConnection(tokenId, cur.tokenId, palette); + projectStore.addConnection(tokenId, cur.tokenId, palette, pinnedColor); } - this.pending = null; + this.clear(); + } + + /** Select a token directly (e.g. clicking a group's pinned-color badge) to open its color popover. */ + selectToken(lineId: string, tokenId: string) { + this.pending = { lineId, tokenId }; + this.adjacencyHint = false; + this.pendingColor = null; + } + + /** Choose a palette color for the current selection without dropping it. */ + setColorForSelection(color: string) { + const cur = this.pending; + if (!cur) return; + if (projectStore.hasGroup(cur.tokenId)) { + projectStore.pinGroupColor(cur.tokenId, color); + this.pendingColor = null; + } else { + // Unlinked word: remember the color for the link about to be created. + this.pendingColor = color; + } + } + + /** Reset the current selection's group color to automatic (or clear a pre-picked color). */ + resetColorForSelection() { + const cur = this.pending; + if (!cur) return; + if (projectStore.hasGroup(cur.tokenId)) { + projectStore.unpinGroupColor(cur.tokenId, settingsStore.settings.palette); + } + this.pendingColor = null; } clear() { this.pending = null; this.adjacencyHint = false; + this.pendingColor = null; } showLinkHint(): boolean { diff --git a/bitext/src/lib/state/settingsNav.svelte.ts b/bitext/src/lib/state/settingsNav.svelte.ts index 5921ec1..34bbee7 100644 --- a/bitext/src/lib/state/settingsNav.svelte.ts +++ b/bitext/src/lib/state/settingsNav.svelte.ts @@ -1,4 +1,4 @@ -/** Jump to Settings → Tokens from elsewhere (line editor hint icon). */ +/** Jump to Text → Word splitting from elsewhere (line editor hint icon). */ class SettingsNavStore { tokensFocusGeneration = $state(0); diff --git a/bitext/src/routes/+layout.svelte b/bitext/src/routes/+layout.svelte index b1e6f4a..83ccfcd 100644 --- a/bitext/src/routes/+layout.svelte +++ b/bitext/src/routes/+layout.svelte @@ -10,6 +10,7 @@ import { settingsStore } from '$lib/state/settings.svelte.js'; import { viewportStore } from '$lib/state/viewport.svelte.js'; import { ThemeProvider } from 'flowbite-svelte'; + import SiteHeader from '$lib/components/layout/SiteHeader.svelte'; let { children } = $props(); @@ -69,6 +70,7 @@
+ {@render children()}
diff --git a/bitext/src/routes/+page.server.ts b/bitext/src/routes/+page.server.ts index 9055dd5..a63d253 100644 --- a/bitext/src/routes/+page.server.ts +++ b/bitext/src/routes/+page.server.ts @@ -1,30 +1,4 @@ -import { getHomePartnerOrder } from '$lib/partners/home-rotation.js'; -import { buildAppStateFromExample } from '$lib/examples/build-app-state.js'; -import { findGalleryBySlug } from '$lib/examples/catalog.js'; -import { findExample } from '$lib/state/examples.js'; -import { decodeState } from '$lib/serialization/decode.js'; +import { loadHomePage } from '$lib/server/home-page-load.js'; import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = ({ url }) => { - const data = url.searchParams.get('data'); - const exampleParam = url.searchParams.get('example'); - - let initialState = data ? decodeState(data) : null; - let exampleInvalid = false; - - if (!initialState && exampleParam) { - const gallery = findGalleryBySlug(exampleParam); - if (gallery) { - initialState = buildAppStateFromExample(findExample(gallery.exampleId)); - } else { - exampleInvalid = true; - } - } - - return { - dataParam: data, - initialState, - exampleInvalid, - homePartnerOrder: getHomePartnerOrder(Date.now()) - }; -}; +export const load: PageServerLoad = ({ url }) => loadHomePage(url); diff --git a/bitext/src/routes/+page.svelte b/bitext/src/routes/+page.svelte index ca21147..fd4e928 100644 --- a/bitext/src/routes/+page.svelte +++ b/bitext/src/routes/+page.svelte @@ -1,57 +1,61 @@ @@ -177,372 +165,210 @@ -
-
-
-

+ +

Word-by-word translation visualizer

+ +
+ +
+ +
- Word-by-word translation visualizer - -
- Examples - Guides - About -
- - -
-
-
-
-
-

- See exactly which word matches which across stacked lines. Add rows for glosses or IPA if - you need them, click a word then its match on the line above or below, and export or share - the diagram, great for lessons, posts, or conlang notes. -

-

- Created by - Dani. See other - tools for linguistics and conlanging. -

-
-
- {#key data.homePartnerOrder[0]} - {@const HomePartnerIntroBanner = homeBannerById[data.homePartnerOrder[0]]} - - {/key} -
-
-
- -
-
-
- -
-
-
-
-

- Preview -

-
- - - -
-
-
- -
- - -
+ {/each}
-
- {#if !settingsStore.settings.autoFit} -

+ + + +

+ + + + +
+
+ + +
+
+
- {#if previewExpand} +
+ + + + + + {#if viewportStore.isNarrow && editorShellStore.sheetOpen} + + + {/if} +
+ + + {#if !viewportStore.isNarrow} + + {/if} +
+ + {#if viewportStore.isNarrow} + +
+
-
-
- -
-
- -
-
- -
-
- {#key data.homePartnerOrder[1]} - {@const HomePartnerSidebarBanner = homeBannerById[data.homePartnerOrder[1]]} - - {/key} -
-

- + {:else} + +

-
-
- - + + + {/if} +
+ +{#if previewExpand} + +{/if} - - - - - + + + + diff --git a/bitext/src/routes/about/+page.svelte b/bitext/src/routes/about/+page.svelte index eeccb68..d8823ae 100644 --- a/bitext/src/routes/about/+page.svelte +++ b/bitext/src/routes/about/+page.svelte @@ -7,10 +7,11 @@ import PartnerBannerRailway from '$lib/components/partners/PartnerBannerRailway.svelte'; import PartnerBannerWise from '$lib/components/partners/PartnerBannerWise.svelte'; import SiteFooter from '$lib/components/layout/SiteFooter.svelte'; + import YouTubeEmbed from '$lib/components/media/YouTubeEmbed.svelte'; import StructuredData from '$lib/components/seo/StructuredData.svelte'; import { breadcrumbList, personCreator } from '$lib/seo/structured-data.js'; import { SITE_AUTHOR_URL } from '$lib/brand.js'; - import { settingsStore } from '$lib/state/settings.svelte.js'; + import { galleryPreviewImageUrl } from '$lib/examples/cdn.js'; const TITLE = 'About'; const DISPLAY_NAME = 'Word Aligner'; @@ -18,6 +19,8 @@ const DESCRIPTION = 'Word Aligner: multi-line word alignment, interlinear glosses and IPA, RTL scripts, word-splitting rules, per-line typography, exports (PNG, SVG, PDF, HTML), and shareable URLs — for learners, teachers, and linguists.'; + const DEMO_VIDEO_ID = '08grRGLk3ms'; + const canonical = $derived(page.url.origin + page.url.pathname); const ogImage = $derived(`${page.url.origin}/api/og`); @@ -29,116 +32,112 @@ personCreator() ]; - const siteTheme = $derived(settingsStore.settings.theme); - const themeIconBtn = - 'box-border m-0 inline-flex h-9 w-9 shrink-0 items-center justify-center border-0 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-gray-400'; - const themeIconActive = `${themeIconBtn} bg-gray-200 text-gray-900 dark:bg-gray-600 dark:text-gray-100`; - const themeIconIdle = `${themeIconBtn} bg-transparent text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800`; - - const EXAMPLES_IMAGE_VERTICAL_CROP = '10%'; - const examplesImageClipPath = `inset(${EXAMPLES_IMAGE_VERTICAL_CROP} 0 ${EXAMPLES_IMAGE_VERTICAL_CROP} 0)`; - - const headingClass = - 'font-heading scroll-mt-20 mt-10 text-xl font-semibold text-gray-900 dark:text-white'; + const eyebrow = + 'font-heading text-xs font-semibold tracking-[0.18em] text-primary-600 uppercase dark:text-primary-400'; + const h2Class = + 'font-heading mt-3 text-3xl font-semibold tracking-tight text-gray-900 sm:text-4xl dark:text-white'; + const cardClass = + 'flex h-full flex-col border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800/40'; + const captionClass = 'mt-3 text-center text-sm leading-snug text-gray-600 dark:text-gray-400'; + const shotFrame = + 'overflow-hidden border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900/50'; const linkClass = 'font-medium text-primary-700 underline decoration-primary-700/40 underline-offset-2 hover:text-primary-800 hover:decoration-primary-800 dark:text-primary-400 dark:decoration-primary-400/50 dark:hover:text-primary-300'; - const feedbackBtnClass = 'inline cursor-pointer border-0 bg-transparent p-0 text-sm text-gray-600 underline decoration-gray-400/50 underline-offset-2 transition-colors hover:text-gray-900 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:text-gray-400 dark:decoration-gray-500/50 dark:hover:text-gray-200 dark:focus-visible:outline-primary-500'; - const tocLinkClass = - 'block rounded-none py-1 text-gray-700 underline decoration-gray-400/40 underline-offset-2 hover:text-gray-900 hover:decoration-gray-600/60 dark:text-gray-300 dark:decoration-gray-500/40 dark:hover:text-white dark:hover:decoration-gray-400/60'; - - /** Shared frame for UI screenshots (mixed aspect ratios). */ - const shotFrame = - 'flex w-full items-center justify-center overflow-x-auto overflow-y-hidden rounded-md border border-gray-200 bg-gray-50 p-2 dark:border-gray-700 dark:bg-gray-800/80'; - const shotImg = - 'max-h-[min(85vh,1280px)] w-full max-w-full object-contain [image-rendering:auto]'; - /** Compact screenshots in the Line editor section so captions sit clearly under each card. */ - const lineEditorShotFrame = - 'flex w-full items-center justify-center overflow-x-auto overflow-y-hidden rounded border border-gray-200 bg-white p-1 dark:border-gray-600 dark:bg-gray-900/50'; - const lineEditorShotImg = - 'max-h-[min(38vh,480px)] w-full max-w-full object-contain object-top [image-rendering:auto] sm:max-h-[min(44vh,520px)]'; - const lineEditorFigureShell = - 'm-0 rounded-lg border border-gray-200 bg-gray-50/80 p-2 shadow-sm dark:border-gray-700 dark:bg-gray-800/40'; - const lineEditorFigcaptionClass = - 'mt-2 border-t border-gray-200 pt-2 text-left text-xs leading-snug text-gray-600 dark:border-gray-600 dark:text-gray-400'; - - type DocShot = { - src: string; - alt: string; - caption: string; - /** Narrow popovers/modals: keep readable width on wide viewports */ - narrow?: boolean; - eager?: boolean; - }; + const ctaPrimary = + 'inline-flex items-center gap-2 border border-primary-600 bg-primary-600 px-5 py-2.5 text-sm font-semibold text-white no-underline transition-colors hover:bg-primary-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:border-primary-500 dark:hover:bg-primary-500'; + const ctaGhost = + 'inline-flex items-center gap-2 border border-gray-300 bg-white/70 px-5 py-2.5 text-sm font-semibold text-gray-800 no-underline transition-colors hover:border-gray-400 hover:bg-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-400 dark:border-gray-600 dark:bg-gray-800/60 dark:text-gray-100 dark:hover:bg-gray-800'; - const lineEditorShots: DocShot[] = [ - { - src: '/screenshots/line-editor.png', - alt: 'Aligner line editor: summary of how words are split (whitespace, extra characters, join marker, punctuation) above stacked lines of text, with a gear control to open word-splitting settings.', - caption: - 'Main editor: stacked lines and a short recap of how text is split into clickable words (gear opens the Tokens tab in Settings).', - eager: true - }, + const featuredExamples = [ { - src: '/screenshots/edit-popover-turkish-line-3.png', - alt: 'Popover for editing line 3: typography controls for font family, text size, word spacing, line reorder arrows, and delete, above a Turkish token row.', - caption: - 'Popover on a line: font, size, spacing, reorder, and whether to draw connectors to the row below.', - narrow: true + slug: 'hebrew-arabic-english-rtl-interlinear', + alt: 'Right-to-left example: Hebrew and Arabic sentences aligned to an English translation, with connectors crossing between the differently ordered words.', + caption: 'Hebrew and Arabic aligned to English (RTL).' }, { - src: '/screenshots/edit-line-modal-turkish-line-3.png', - alt: 'Edit line modal for line 3: Turkish text with pipe characters as morpheme boundaries, a summary of split and join rules, optional right-to-left row toggle, color-coded token preview chips, and Done.', - caption: - 'Edit line dialog: change the sentence and see how it splits into highlighted word boxes.', - narrow: true + slug: 'conlang-custom-font-interlinear-gloss', + alt: 'Conlang example: a constructed-language sentence in a custom script font, with an interlinear gloss and an English translation joined by connectors.', + caption: 'A conlang with a custom font and a gloss.' } ]; - const previewShots: DocShot[] = [ + const steps = [ + { + n: '1', + title: 'Type or paste two lines', + body: 'A sentence and its translation, one per line. Add more rows for a gloss or an IPA transcription whenever you need them.' + }, + { + n: '2', + title: 'Click a word, then its match', + body: 'A curved connector draws the link. Link one word to many, and let connectors cross freely when the translation reorders the words.' + }, { - src: '/screenshots/preview-turkish-example.png', - alt: 'Preview of the Turkish interlinear example: four stacked rows (glosses, IPA, Turkish, English) with curved colored connectors linking aligned tokens across adjacent lines.', - caption: 'Live preview with connectors across a multi-line Turkish interlinear.', - eager: true + n: '3', + title: 'Export or share', + body: 'Download PNG, SVG, PDF, or a self-contained HTML file, or copy a link that reproduces every word box and connector.' } ]; - const settingsTabShots: DocShot[] = [ + type FeatureIcon = + | 'link' + | 'tiers' + | 'rtl' + | 'fonts' + | 'tokens' + | 'export' + | 'palette' + | 'free' + | 'api'; + type Feature = { title: string; icon: FeatureIcon; body: string }; + const features: Feature[] = [ { - src: '/screenshots/settings-tab-1-style.png', - alt: 'Settings Style tab: UI theme, background mode, connector curve style, line thickness and opacity, default and per-pair line gaps, and options to hide preview chrome.', - caption: 'Style — background, connectors, spacing, preview chrome.' + title: 'You draw every link', + icon: 'link', + body: 'No machine translation. One-to-many and many-to-one connectors, crossing freely for reordered translations, so the alignment stays yours and stays accurate.' }, { - src: '/screenshots/settings-tab-2-colors.png', - alt: 'Settings Colors tab: toggle to match token colors to links, choice between coloring token text or backgrounds, and pastel, vivid, and academic palette buttons with swatches.', - caption: 'Colors — palettes and how link colors apply to tokens.' + title: 'Gloss and IPA tiers', + icon: 'tiers', + body: 'Stack a morpheme-by-morpheme gloss or an IPA row above or below any line for Leipzig-style interlinear layouts.' }, { - src: '/screenshots/settings-tab-3-tokenization.png', - alt: 'Settings Tokens tab: show line numbers, extra token split characters, single-character merge marker, and punctuation tokenization with optional custom punctuation list.', - caption: 'Tokens — how text becomes word boxes: splits, join marker, punctuation.' + title: 'Right-to-left and mixed scripts', + icon: 'rtl', + body: 'Hebrew and Arabic sit alongside left-to-right text, with direction set per line.' }, { - src: '/screenshots/settings-tab-4-custom-fonts-library.png', - alt: 'Settings Fonts tab: library of uploaded custom fonts with remove actions, plus controls to add fonts from Google Fonts by family name.', - caption: 'Fonts — custom uploads and Google Fonts lookup.' - } - ]; - - const exportShareShots: DocShot[] = [ + title: 'Per-line typography', + icon: 'fonts', + body: 'Set font, size, and word spacing on each line, from Google Fonts or an uploaded file. Exports keep custom-font shaping.' + }, + { + title: 'Tokenization control', + icon: 'tokens', + body: 'Choose how text splits into linkable words: extra separators for morpheme boundaries and a join marker for fixed expressions.' + }, + { + title: 'Exports that match the preview', + icon: 'export', + body: 'PNG, SVG, PDF, and self-contained HTML, plus a QR code, all matching what you see on screen.' + }, + { + title: 'Palettes and styles', + icon: 'palette', + body: 'Twelve color palettes, curved or straight connectors, light or dark canvas, and word colors that follow the links.' + }, { - src: '/screenshots/export-card.png', - alt: 'Export card with buttons to download the alignment as PNG, SVG, PDF, or self-contained HTML, plus options related to QR and sharing.', - caption: 'Export — PNG, SVG, PDF, and HTML.' + title: 'Free and account-less', + icon: 'free', + body: 'The whole diagram lives in a shareable URL, so there is nothing to save, no backend storage, and nothing to sign up for.' }, { - src: '/screenshots/share-card.png', - alt: 'Share card with Copy share link, More options for QR and advanced sharing, and icons to share on X, Facebook, and Reddit.', - caption: 'Share — link, QR, social targets, and developer JSON copy from the dialog.' + title: 'API, MCP, and an agent skill', + icon: 'api', + body: 'A free HTTP API, an MCP server, and an installable skill let an AI assistant build a diagram from a plain request.' } ]; @@ -167,345 +166,450 @@ -
-
-
- +{#snippet fIcon(name: FeatureIcon)} + +{/snippet} + +
+ +
+ + +
+ + +

Free · runs in your browser

+

+ About {DISPLAY_NAME} +

+

+ A free, browser-based tool for drawing word-to-word and morpheme-to-morpheme links between + stacked lines of text: bilingual glosses, interlinear annotations, classroom handouts, and + social posts. Everything runs in your browser; your sentences are not stored on our servers + unless you choose to share them. +

+ -
-
-

- About {DISPLAY_NAME} -

-

- {DISPLAY_NAME} is a free, - browser-based tool for drawing word-to-word and morpheme-to-morpheme links between stacked lines of - text such as bilingual glosses, interlinear annotations, classroom handouts, and social posts. Everything - runs in your browser; your sentences are not stored on our servers unless you choose to share them. -

+
+ +

A short walkthrough of building an alignment from scratch.

+
+
+ - + +
+
+

How it works

+

From two lines to a diagram in a minute

+
+ {#each steps as step (step.n)} +
+ + {step.n} + +

+ {step.title} +

+

+ {step.body} +

+
+ {/each} +
-

Line editor

-

- You can add several lines (for example source, IPA, glosses, and a free translation). Each line - has its own font (Google Fonts or an uploaded file), text size, horizontal spacing between - words, and optional right-to-left layout for scripts such as Hebrew or Arabic. Lines can be - reordered so that only adjacent rows are - linkable — - {DISPLAY_NAME} always links the line above to the line directly below it. Open a line’s popover for - quick typography controls, or the full - Edit line - dialog to change text and see a live preview of how it splits into word boxes. -

-
- {#each lineEditorShots as shot, i (shot.src)} -
-
+
+
{shot.alt}
-
- {shot.caption} +
+ The editor: lines and settings on the right, the live diagram on the left.
- {/each} -
+
+
-

Preview and alignment

-

- Click tokens to create colored alignment links. Many-to-many groupings share one color so you - can see which tokens belong to the same correspondence. For each pair of adjacent lines you can - adjust the vertical gap and optionally hide the connector curves - while keeping the links in the data model — useful when a gloss row sits tight under a sentence. The - preview supports a clean “hide chrome” mode and fullscreen for screenshots. -

-
- {#each previewShots as shot (shot.src)} -
-
+ +
+
+

The diagram

+

Word order stays visible

+

+ Each sentence keeps its own line and the connectors draw between them, so reordering, + splits, and dropped words stay obvious instead of being flattened into a stacked gloss. +

+
+
{shot.alt}
-
- {shot.caption} -
- {/each} -
+
+ -

Settings

-

- The settings panel uses icons for four areas. What each one does in plain language: -

-
    -
  • - Style — light or dark UI, background of the - preview background (light or dark), whether connectors are curved or straight, how thick and faint - they are, default vertical space between lines, and optional per-pair spacing overrides. You can - also hide preview controls for a clean screenshot and open fullscreen from the preview toolbar. -
  • -
  • - Colors — color palettes for links, and whether - matching words are highlighted by coloring the text or the word background (including in exports - when that option is on). -
  • -
  • - Tokens — in the UI this tab is labeled - “Tokens”; internally we talk about - tokenization, meaning “how your text is - cut into clickable word boxes.” Here you can show line numbers; choose extra characters that - force a new word (for example | or - - when you need morpheme boundaries); set a - join character so two written words still - count as one box for linking (shown with a space in the preview); and optionally split punctuation - into its own boxes or limit which punctuation splits. -
  • -
  • - Fonts — upload font files for custom scripts - and pick them per line, or add families from Google Fonts by name. -
  • -
-

- The short summary next to the line editor (“Whitespace splits words…”) mirrors the same rules as - the Tokens tab; use the gear button there to jump straight to those controls. -

-
- {#each settingsTabShots as shot (shot.src)} -
-
- {shot.alt} -
-
- {shot.caption} -
-
- {/each} -
+ +
+
+

Features

+

What's inside

+
+ {#each features as feature (feature.title)} +
+ + {@render fIcon(feature.icon)} + +

+ {feature.title} +

+

+ {feature.body} +

+
+ {/each} +
+
+
-

Export and share

-

- Download the visualization as PNG, SVG, PDF, - or a self-contained HTML file. You can - also build a share link: the full project - and visual settings are encoded in the - ?data= - URL parameter so anyone who opens the link sees the same alignment. The Share dialog adds a QR code, - social targets, and a Data object action for - JSON shaped like a curated preset (useful for authors and debugging). -

-
- {#each exportShareShots as shot (shot.src)} -
-
- {shot.alt} -
-
- {shot.caption} -
-
- {/each} -
+ +
+
+

A closer look

+

Style and export

+
+
+
+ Style tab: a light or dark canvas toggle, a grid of twelve color palettes, a toggle to color words to match their links, and connector shape and thickness controls. +
+
+ Style: palettes, light or dark canvas, curved or straight connectors, thickness. +
+
+
+
+ Export tab: download buttons for PNG, SVG, PDF, HTML, and QR, and a share row with social buttons and a Copy link button. +
+
+ Export: PNG, SVG, PDF, HTML, a QR code, and a shareable link. +
+
+
+
+
-

Examples and motion demos

-

- Use Load example on the main page to open curated - projects (simple bilingual pair, Turkish interlinear with IPA and glosses, Hebrew and Arabic with - English, Tagalog compounds, Japanese–Chinese–English, and more). They illustrate RTL, multi-line stacks, - and tricky word-splitting cases. The clips below are the same motion demos as on the home page: linking - in the editor, and a conlang layout with a custom font and glosses. -

-
-
-
- Animated demo in Aligner: creating word links between “Hello world” and its French translation + +
+
+

Examples

+

Start from a ready-made example

+

+ The gallery covers bilingual pairs, interlinear stacks, right-to-left scripts, and tricky + word-splitting cases. Open any one in the editor and adapt it. +

+
+ {#each featuredExamples as ex (ex.slug)} +
+ + {ex.alt} + +
{ex.caption}
+
+ {/each}
-
- Linking words between two sentences -
-
-
+ +
+ + + +
+
+

Origin

+

Where it came from

- Conlang alignment example in Aligner: custom script font on the source line, interlinear glosses, and English translation with connectors +

+ Word Aligner started in the conlang community. People who invent languages like to post + word-by-word breakdowns of a sentence to show how the grammar works, and there was no real + tool for it. They lined up words and drew arrows by hand in Paint or PowerPoint, in + whatever was open. It looked rough and took an afternoon. +

+

+ So this became a single editor: type two lines, click a word and then its match, and a + connector draws itself. Add a gloss or an IPA row, use a custom font for your script, and + export a clean image or share the link. It caught on in the conlang subreddit first, then + language teachers and linguists picked it up for the same reason. Seeing which word became + which is useful well beyond conlanging. +

-
- Conlang with a custom font and interlinear glosses -
-
-
+
+ -

Partner links

-

- {DISPLAY_NAME} stays free and without aggressive ads. Hosting and ongoing upkeep still have a cost, - so I add a few optional partner links. Use them if you were already considering the service, it helps - keep the site running. The referral bonuses come from the provider. These are services I use myself. -

-
- - - - -
+ +
+

+ Partner links +

+

+ {DISPLAY_NAME} stays free and without aggressive ads. Hosting and ongoing upkeep still have a cost, + so I add a few optional partner links. Use them if you were already considering the service, it + helps keep the site running. The referral bonuses come from the provider. These are services I use + myself. +

+
+ + + + +
-

About the creator

-

- {DISPLAY_NAME} is built by Dani Polani — a fantasy author, the creator of the constructed language - Lemu Teloku, and a maker of tools for conlangers and linguists. A psychologist and linguist by training - and a self-taught developer, Dani builds small, focused tools and likes automating the tedious parts. -

-

- The same attention to interlinear glosses and Leipzig-style conventions that goes into - documenting a constructed language shaped this tool. Alongside the language work there is a - wider creative world — drawings, an encyclopedia of Lemu Teloku and its setting, and other - handmade art projects. Offline, Dani is fond of literature, nineteenth-century technology, cats, - and seals. -

-

- More of Dani's work and tools: - - danipolani.github.io - . -

+

+ About the creator +

+
+

+ {DISPLAY_NAME} is built by Dani Polani, a fantasy author, the creator of the constructed language + Lemu Teloku, and a maker of tools for conlangers and linguists. A psychologist and linguist by + training and a self-taught developer, Dani builds small, focused tools and likes automating the + tedious parts. +

+

+ The same attention to interlinear glosses and Leipzig-style conventions that goes into + documenting a constructed language shaped this tool. Alongside the language work there is a + wider creative world of drawings, an encyclopedia of Lemu Teloku and its setting, and other + handmade art projects. Offline, Dani is fond of literature, nineteenth-century technology, + cats, and seals. +

+

+ More of Dani's work and tools: + + danipolani.github.io + . +

+
-

Contact

-

- Questions or feedback about {DISPLAY_NAME}: - {SITE_CONTACT_EMAIL} - · - -

+ Contact + +

+ Questions or feedback about {DISPLAY_NAME}: + {SITE_CONTACT_EMAIL} + · + +

-

Privacy

-

- We do not run accounts or store your text on our infrastructure. Details on analytics, feedback, - and fonts are in the - privacy policy. -

+

+ Privacy +

+

+ We do not run accounts or store your text on our infrastructure. Details on analytics, + feedback, and fonts are in the + privacy policy. +

+
- + +
+ +
+

+ Draw your first alignment +

+

+ No account, no machine translation. Open the editor and link two words to see how it feels. +

+ +
+ +
+
+
diff --git a/bitext/src/routes/api/+page.svelte b/bitext/src/routes/api/+page.svelte index 3615957..0672883 100644 --- a/bitext/src/routes/api/+page.svelte +++ b/bitext/src/routes/api/+page.svelte @@ -324,8 +324,7 @@ palette pastel vivid - academicpastel vivid pastel Color palette for connection lines and token tints. diff --git a/bitext/src/routes/api/align/openapi.json/+server.ts b/bitext/src/routes/api/align/openapi.json/+server.ts index a19f52f..805a18e 100644 --- a/bitext/src/routes/api/align/openapi.json/+server.ts +++ b/bitext/src/routes/api/align/openapi.json/+server.ts @@ -169,7 +169,7 @@ export const GET: RequestHandler = ({ url }) => { properties: { palette: { type: 'string', - enum: ['pastel', 'vivid', 'academic'], + enum: ['pastel', 'vivid'], description: 'Color palette for connection lines. Default: pastel.' }, lineStyle: { diff --git a/bitext/src/routes/examples/+page.svelte b/bitext/src/routes/examples/+page.svelte index 7373660..bb054c5 100644 --- a/bitext/src/routes/examples/+page.svelte +++ b/bitext/src/routes/examples/+page.svelte @@ -2,6 +2,7 @@ import { page } from '$app/state'; import { resolve } from '$app/paths'; import { ALIGNER_DISPLAY_NAME } from '$lib/brand.js'; + import PageHero from '$lib/components/layout/PageHero.svelte'; import SiteFooter from '$lib/components/layout/SiteFooter.svelte'; import StructuredData from '$lib/components/seo/StructuredData.svelte'; import { galleryPreviewImageUrl } from '$lib/examples/cdn.js'; @@ -54,37 +55,35 @@
-
- -

- {TITLE} -

-

+ + {#snippet lede()} {DESCRIPTION} -

-

- New to the notation? Start with the - glossing abbreviations cheat sheet, then open any example below in the editor. -

-
+ + New to the notation? Start with the + glossing abbreviations cheat sheet, then open any example below in the editor. + + {/snippet} + diff --git a/bitext/src/routes/examples/[slug]/+page.svelte b/bitext/src/routes/examples/[slug]/+page.svelte index ca0c01b..ab93549 100644 --- a/bitext/src/routes/examples/[slug]/+page.svelte +++ b/bitext/src/routes/examples/[slug]/+page.svelte @@ -2,6 +2,7 @@ import { page } from '$app/state'; import { resolve } from '$app/paths'; import { ALIGNER_DISPLAY_NAME } from '$lib/brand.js'; + import PageHero from '$lib/components/layout/PageHero.svelte'; import SiteFooter from '$lib/components/layout/SiteFooter.svelte'; import ExampleSections from '$lib/components/examples/ExampleSections.svelte'; import PartnerBannerById from '$lib/components/partners/PartnerBannerById.svelte'; @@ -74,23 +75,22 @@
-
- -

- {entry.title} -

-

+ + {#snippet lede()} {entry.description} -

-
+ {/snippet} +
+

+ {rel.title}

- + {/each} diff --git a/bitext/src/routes/guide/+page.svelte b/bitext/src/routes/guide/+page.svelte index 82a396d..cbe5609 100644 --- a/bitext/src/routes/guide/+page.svelte +++ b/bitext/src/routes/guide/+page.svelte @@ -14,6 +14,7 @@ - {#each GUIDES as guide (guide.path)} -
  • - +
  • {/each} -
  • - +
  • diff --git a/bitext/static/screenshots/about/editor-overview.png b/bitext/static/screenshots/about/editor-overview.png new file mode 100644 index 0000000..69cf400 Binary files /dev/null and b/bitext/static/screenshots/about/editor-overview.png differ diff --git a/bitext/static/screenshots/about/preview-clean.png b/bitext/static/screenshots/about/preview-clean.png new file mode 100644 index 0000000..d5a08f8 Binary files /dev/null and b/bitext/static/screenshots/about/preview-clean.png differ diff --git a/bitext/static/screenshots/about/rail-export.png b/bitext/static/screenshots/about/rail-export.png new file mode 100644 index 0000000..3369124 Binary files /dev/null and b/bitext/static/screenshots/about/rail-export.png differ diff --git a/bitext/static/screenshots/about/rail-style.png b/bitext/static/screenshots/about/rail-style.png new file mode 100644 index 0000000..c76ce68 Binary files /dev/null and b/bitext/static/screenshots/about/rail-style.png differ diff --git a/docs/ui-rework/README.md b/docs/ui-rework/README.md new file mode 100644 index 0000000..99ebc29 --- /dev/null +++ b/docs/ui-rework/README.md @@ -0,0 +1,39 @@ +# Mobile-first UI rework — concept mock + +Interactive mock of a proposed mobile-first interface for Word Aligner. + +- **File:** [mobile-redesign.html](mobile-redesign.html) — open in a browser (self-contained, no build). +- **Live artifact:** https://claude.ai/code/artifact/9c48a6a5-f31d-4bf3-a3af-d8b8f417fdad + +This is a design mock, not wired to the app. The rationale and flow are written into the +page itself. + +## Concept in one line + +The diagram is the work; the chrome gets out of its way. Canvas fills the screen, three +labeled modes live in a thumb-reach bottom bar, and the confusing fine-tuning is folded away. + +## The three modes + +- **Text** — write/paste the two lines. Comfortable fields, arrow reorder, RTL, add line. + Per-line font/size behind an "Aa" button. Auto-fit explained inline. +- **Link** (default) — the live canvas. Tap a word, then its match on the next line; a banner + echoes the pinned word. Tap a thread to delete via a real target. Pinch to zoom. +- **Style** — theme swatches, palette, light/dark, straight/curved. Only choices that always + look good. A single **Fine-tune** disclosure holds thickness, opacity, spacing, + tokenization, and fonts. + +**Export** is the one primary action, kept in the top bar. On desktop the sheets dock as a +right rail. + +## Foolproofing ("защита от дурака") + +- Opens on a real, connected example — never a blank canvas. +- Auto-fit means any input lays out on one row; no wrapping or overflow. +- Only safe, curated controls are always visible; the messy ones sit behind Fine-tune. +- Padded word targets, thread-delete popover, 44px minimum — fewer mis-taps. + +## Not addressed here (follow-ups) + +Routing of the SEO/marketing content off the editor, real per-line "Aa" sheet contents, and +the desktop rail interaction are described but not mocked in detail. diff --git a/docs/ui-rework/mobile-redesign.html b/docs/ui-rework/mobile-redesign.html new file mode 100644 index 0000000..a7233d2 --- /dev/null +++ b/docs/ui-rework/mobile-redesign.html @@ -0,0 +1,795 @@ + + + + + +Word Aligner — mobile-first UI rework + + + + + + + +
    +
    +
    + + Word Aligner · UI rework +
    +
    +
    +

    Mobile-first concept

    +

    The diagram is the work. The tool should get out of its way.

    +

    A redesign for phones, where most people already use Word Aligner. Type two lines, connect words with a tap, and the result looks finished before you touch a single setting. The controls that used to confuse people are still here, just folded away until you want them.

    +
    + Surface phone, 375px up + Model canvas + 3 modes + State interactive mock +
    +
    +
    + +
    +
    +
    + 9:41 + +
    + +
    +
    + + Word Aligner +
    +
    + + +
    +
    + +
    + +
    + +
    English → Spanish
    +
    + + + +
    +
    +
    +
    Your text
    Each line becomes a row in the diagram
    +
    +
    +
    +
    EN
    +
    Line 1
    She is reading a book
    + + +
    +
    +
    ES
    +
    Line 2
    Ella está leyendo un libro
    + + +
    + +
    + + Long lines shrink to fit on one row automatically. You never get a wrapped, broken diagram. +
    +
    +
    + +
    +
    +
    +
    Style
    Every choice here looks good on its own
    +
    +
    +
    Theme
    +
    +
    Classic
    +
    Aurora
    +
    Atlas
    +
    Bauhaus
    +
    Nouveau
    +
    + +
    Palette
    +
    + + + + + +
    + +
    Background
    +
    +
    Threads
    +
    + +
    + +
    +

    Rarely needed. The defaults above already produce a clean diagram.

    +
    Thread thickness3 px
    +
    Opacity90%
    +
    Word spacing6 px
    + +
    +
    +
    +
    + +
    +
    +
    +
    Export & share
    Matches the preview exactly
    +
    +
    +
    +
    PNG
    image
    +
    SVG
    vector
    +
    PDF
    print
    +
    +
    Resolution
    +
    + +
    +
    +
    + +
    + + + +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +

    Why rework it

    +

    On a phone today, the tool is buried in a page

    +

    Word Aligner works, and the recent auto-fit and pinch-zoom already fixed the worst of the output. What is left is the shape of the screen. The editor sits inside a long marketing scroll, so the controls that change the diagram live far below the diagram itself.

    +
    +
    +
    + 01 +

    Controls are stranded at the bottom

    +

    Settings, style, and export stack below the preview and the examples. To change a color you scroll past the whole diagram, then scroll back to check the result. On a phone that is a lot of thumb travel for one small change.

    +
    +
    + 02 +

    Four icon-only tabs, no labels

    +

    Style, Colors, Tokens, and Fonts sit behind four unlabeled icons. People do not know which one holds the setting they want, so the fine-tuning reads as intimidating rather than optional.

    +
    +
    + 03 +

    The core gesture is unguided

    +

    Connecting words is tap-then-tap, which is good. But nothing on screen teaches it, the word targets are small, and removing a thread means hitting a thin line. First-timers guess.

    +
    +
    + 04 +

    No single home for the work

    +

    Editor, preview, settings, export, and share are five separate blocks in a column. There is no one place that says "this is where you make the picture."

    +
    +
    +
    +
    + +
    +
    +
    +

    The new shape

    +

    One canvas, three modes, everything in thumb reach

    +

    The diagram fills the screen and stays put. A bottom bar holds the three things you actually do, and each opens a sheet that slides up over the lower canvas so you always see the effect of a change. Export is the one primary action, kept in the top bar.

    +
    +
    +
    + Mode 1 +

    Text

    +

    Write or paste the two lines. Comfortable full-width fields, reorder by arrows, mark right-to-left, add a line.

    +
      +
    • Starts with a real example, never a blank canvas
    • +
    • Auto-fit note explains why long text still looks right
    • +
    • Per-line font and size tucked behind an "Aa" button
    • +
    +
    +
    + Mode 2 · default +

    Link

    +

    The canvas is live. Tap a word, then tap its match on the next line. A banner echoes the pinned word so the next step is obvious.

    +
      +
    • Tap a thread to get a real delete button, not a hairline
    • +
    • Pinch to zoom for small text, shown by a hint pill
    • +
    • Word targets padded for fingers
    • +
    +
    +
    + Mode 3 +

    Style

    +

    Theme, palette, light or dark, straight or curved. Only choices that always look good. The confusing controls sit behind one "Fine-tune" row.

    +
      +
    • Twelve themes as swatches, one tap to restyle
    • +
    • Fine-tune holds thickness, spacing, tokens, fonts
    • +
    • Most people never open it, and that is the point
    • +
    +
    +
    + +
    +
    +
    +
    +
    9:41
    +
    Word Aligner
    +
    +
    Now tap the word below that meansreading
    +
    English → Spanish
    +
    +
    +
    +
    +
    Link · mid-gesture
    The pinned word is echoed; the next tap has one clear target.
    +
    + +
    +
    +
    +
    9:41
    +
    Word Aligner
    +
    +
    +
    +
    +
    Style
    Fine-tune expanded
    +
    +
    Background
    +
    +
    + +
    +

    Rarely needed. The defaults already produce a clean diagram.

    +
    Thread thickness3 px
    +
    Opacity90%
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    Style · fine-tune
    Progressive disclosure keeps the intimidating controls one tap away, not in your face.
    +
    +
    +
    +
    + +
    +
    +
    +

    Protection from a careless run

    +

    A good result even when the user does not read anything

    +

    The goal you set: someone types junk without understanding the tool and still gets something worth sharing. That is a design property, not a hope. It comes from what the interface refuses to let go wrong.

    +
    +
    +
    + Default +

    It is never empty or blank

    +

    The canvas opens on a real, connected example. The first thing a new user sees is a finished diagram, so they copy the pattern instead of facing an empty box.

    +
    +
    + Fit +

    Bad input still lays out well

    +

    Auto-fit shrinks any line to a single row. A 200-character paste does not wrap, overflow, or break the threads. It just gets smaller and stays readable.

    +
    +
    + Safe surface +

    The easy controls cannot look wrong

    +

    Style, palette, background, and thread shape are the only always-visible choices. Every combination is curated to look designed. The controls that can make a mess are behind Fine-tune.

    +
    +
    + Targets +

    Fingers, not pixels

    +

    Words carry padded hit areas, threads delete through a popover, and every button clears 44px. Fewer mis-taps means fewer accidental, ugly edits to undo.

    +
    +
    +
    +
    + +
    +
    +
    +

    Decisions and why

    +

    What changed, and the reasoning behind it

    +
    +
    + + + + + + + + + + + + +
    DecisionReasoning
    Canvas-first app shell, not a marketing scrollThe diagram is the product. On a phone it should own the screen, with the SEO and marketing content kept on separate routes below or off the editor entirely.
    Three labeled modes in a bottom bar: Text, Link, StyleThree verbs match what people do. A bottom bar is the natural thumb zone and the labels remove the guesswork of four unnamed icon tabs.
    Link is the default modeConnecting words is the core loop and the part people find least obvious. Landing there, with a teaching banner, puts the main gesture front and center.
    Controls open as sheets over the lower canvasYou see the change while you make it. No scrolling away from the diagram to reach a slider and back to check it.
    Export is the single primary action, kept in the top barOne clear call to action per screen. Export is the outcome, so it stays visible and distinct from the three editing modes.
    Fine-tuning folded behind one disclosureThickness, opacity, spacing, tokenization, and fonts matter to a few users and confuse the rest. Progressive disclosure serves both without a mode of their own.
    Start on a real exampleAn empty editor invites bad first attempts. A working diagram is a template people edit toward, which raises the floor on output quality.
    Bigger link and delete targetsTouch accuracy drives error rate. Padded words and a thread popover cut accidental edits, which is most of what makes a diagram look careless.
    +
    +
    +
    + +
    +
    +
    +

    Nothing is lost

    +

    Where every current control moves

    +

    The redesign relocates features, it does not drop them. Each capability that exists today has a clear home in the new shape.

    +
    +
    +
    +

    Today

    +
    Line editor cardsText sheet
    +
    Style picker popoverStyle sheet, top row
    +
    Colors tabStyle sheet, palette
    +
    Appearance slidersFine-tune disclosure
    +
    Tokens & Fonts tabsFine-tune links
    +
    Export card + Share rowExport sheet
    +
    Per-line settings sheet→ line "Aa" button
    +
    Fullscreen preview→ the canvas is already full-bleed
    +
    +
    +

    Kept as-is

    +
    Tap-tap to connectsame logic, clearer affordance
    +
    Auto-fiton by default, now explained
    +
    Pinch-zoom and panunchanged, with the hint pill
    +
    Tap a thread to deletenow via a popover target
    +
    Share URL stateunchanged serialization
    +
    Export parityexport still matches preview
    +
    +
    +
    +
    + +
    +
    +
    +

    Scaling up

    +

    On desktop, the sheets become a rail

    +

    The same model widens cleanly. The canvas takes the center, and the three modes dock as a persistent right rail instead of sliding sheets. Nothing is relearned moving between phone and desktop.

    +
    +
    +
    +
    +
    +
    English → Spanish
    +
    +
    +
    Text
    +
    EN
    She is reading a book
    +
    Style
    +
    +
    +
    Export
    +
    PNG
    SVG
    PDF
    +
    +
    +
    +
    +
    + +
    +
    +

    Word Aligner · mobile-first UI rework · interactive concept mock

    +

    Try the phone at the top: tap Text, Link, or Style, open Fine-tune, or hit Export. This is a static mock of proposed structure, not the live app.

    +
    +
    + + + +