@inkra/react 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +137 -2
  2. package/THIRD_PARTY_NOTICES.md +9 -0
  3. package/Xiaolai-OFL.txt +94 -0
  4. package/dist/PencilIcon.d.ts +14 -1
  5. package/dist/PencilIcon.js +88 -52
  6. package/dist/PencilText.js +4 -1
  7. package/dist/assets/ui-hand-xiaolai-extended.woff2 +0 -0
  8. package/dist/assets/ui-hand-xiaolai.woff2 +0 -0
  9. package/dist/assets/xiaolai.css +17 -0
  10. package/dist/form/EditablePencilText.js +4 -2
  11. package/dist/guide/index.d.ts +77 -0
  12. package/dist/guide/index.js +379 -0
  13. package/dist/i18n/catalog.d.ts +1 -1
  14. package/dist/i18n/catalog.js +8 -0
  15. package/dist/image-viewer/index.d.ts +2 -0
  16. package/dist/image-viewer/index.js +28 -28
  17. package/dist/index.d.ts +4 -0
  18. package/dist/index.js +4 -0
  19. package/dist/infinite-scroll/gesture.d.ts +19 -0
  20. package/dist/infinite-scroll/gesture.js +218 -0
  21. package/dist/infinite-scroll/index.d.ts +55 -0
  22. package/dist/infinite-scroll/index.js +543 -0
  23. package/dist/infinite-scroll/model.d.ts +33 -0
  24. package/dist/infinite-scroll/model.js +168 -0
  25. package/dist/internal/iconFill.d.ts +16 -0
  26. package/dist/internal/iconFill.js +275 -0
  27. package/dist/internal/surfaceRenderer.d.ts +4 -4
  28. package/dist/internal/surfaceRenderer.js +2 -2
  29. package/dist/internal/textRenderer.d.ts +1 -1
  30. package/dist/internal/textRenderer.js +23 -11
  31. package/dist/inventory-grid/index.js +13 -3
  32. package/dist/inventory-grid/outline.d.ts +9 -0
  33. package/dist/inventory-grid/outline.js +54 -0
  34. package/dist/loading/index.d.ts +58 -0
  35. package/dist/loading/index.js +188 -0
  36. package/dist/loading/locales.d.ts +15 -0
  37. package/dist/loading/locales.js +34 -0
  38. package/dist/loading/model.d.ts +6 -0
  39. package/dist/loading/model.js +29 -0
  40. package/dist/masonry/index.d.ts +25 -0
  41. package/dist/masonry/index.js +227 -0
  42. package/dist/masonry/layout.d.ts +35 -0
  43. package/dist/masonry/layout.js +67 -0
  44. package/dist/number-ticker/RollingValue.d.ts +6 -0
  45. package/dist/number-ticker/RollingValue.js +10 -0
  46. package/dist/number-ticker/index.d.ts +4 -0
  47. package/dist/number-ticker/index.js +55 -7
  48. package/dist/number-ticker/rolling.d.ts +35 -0
  49. package/dist/number-ticker/rolling.js +87 -0
  50. package/dist/selection/CircularSwitchVisual.d.ts +8 -0
  51. package/dist/selection/CircularSwitchVisual.js +82 -0
  52. package/dist/selection/Switch.d.ts +7 -0
  53. package/dist/selection/Switch.js +15 -3
  54. package/dist/slider/circular.d.ts +35 -0
  55. package/dist/slider/circular.js +196 -0
  56. package/dist/slider/circularMath.d.ts +10 -0
  57. package/dist/slider/circularMath.js +34 -0
  58. package/dist/slider/index.d.ts +5 -0
  59. package/dist/slider/index.js +5 -3
  60. package/dist/styles/base.css +4 -2
  61. package/dist/styles/breadcrumb.css +1 -1
  62. package/dist/styles/guide.css +202 -0
  63. package/dist/styles/image-viewer.css +20 -0
  64. package/dist/styles/infinite-scroll.css +171 -0
  65. package/dist/styles/inventory-grid.css +20 -8
  66. package/dist/styles/loading.css +348 -0
  67. package/dist/styles/markdown.css +1 -1
  68. package/dist/styles/masonry.css +68 -0
  69. package/dist/styles/number-ticker.css +47 -1
  70. package/dist/styles/selection.css +50 -0
  71. package/dist/styles/slider.css +65 -0
  72. package/dist/styles/slot.css +1 -1
  73. package/dist/styles.css +1180 -192
  74. package/dist/toast/manager.d.ts +4 -4
  75. package/dist/tokens.css +13 -10
  76. package/package.json +28 -2
package/README.md CHANGED
@@ -17,6 +17,52 @@ Inkra components require a React client boundary. Next.js and React Server Compo
17
17
 
18
18
  ## Usage
19
19
 
20
+ ### Guided And Asynchronous Experiences
21
+
22
+ `Guide.Root`, `Guide.Trigger` and `Guide.Step` compose a targeted walkthrough.
23
+ `showSkip` exposes skipping; `onSkip` is separate from `onFinish`, so leaving
24
+ early never reports completion. Controlled `open` and `step`, target resolution,
25
+ focus restoration and localized labels are available.
26
+
27
+ `Loading` supports `inline`, `region` and `fullscreen` carriers with six layout
28
+ presets: `brand-center`, `scene-bottom`, `intel`, `staged`, `edge` and `skeleton`.
29
+ Supply real progress or `null` for indeterminate progress. The application owns
30
+ `status` and the cancel/retry/continue actions. Fullscreen blocking and scroll
31
+ locking can be configured independently; the edge preset is non-blocking by
32
+ default. Backgrounds, logos, stages, six logical content slots and rotating
33
+ ReactNode tips share the same component. Tips support manual navigation, pause,
34
+ controlled indices and sequential or non-repeating random rotation.
35
+
36
+ `InfiniteScroll` loads at `top`, `bottom`, `left` or `right` boundaries. Configure
37
+ automatic boundary loading and touch pull-and-release separately per direction;
38
+ buttons remain available for keyboard use. `onLoad` receives an abort signal,
39
+ direction and source. Stable item identities support anchored prepends, while
40
+ errors, retry, exhausted directions and in-flight requests remain explicit.
41
+
42
+ `Masonry` arranges measured items in shortest-column or sequential order while
43
+ retaining DOM order. It supports adaptive or fixed columns, independent gaps,
44
+ RTL and dynamic item sizes. Supply unique stable keys, image alternatives and
45
+ your own data fetching, optionally through `InfiniteScroll`:
46
+
47
+ ```tsx
48
+ <InfiniteScroll
49
+ directions="bottom"
50
+ hasMore={hasMore}
51
+ onLoad={({ signal }) => fetchNextPage(signal)}
52
+ style={{ maxHeight: 640, overflow: 'auto' }}
53
+ >
54
+ <Masonry
55
+ items={items}
56
+ getItemKey={item => item.id}
57
+ minColumnWidth={220}
58
+ renderItem={({ item }) => <article>{item.content}</article>}
59
+ />
60
+ </InfiniteScroll>
61
+ ```
62
+
63
+ These APIs are included in this source candidate. Local browser checks are not
64
+ real-device certification or a claim that this candidate has been published.
65
+
20
66
  Import the component styles once near the application entry point:
21
67
 
22
68
  ```tsx
@@ -435,6 +481,22 @@ motion and SSR show deterministic final values. Intermediate frames are visual;
435
481
  the polite announcement updates only at the final value. Use `getValueLabel`
436
482
  when units or custom rendering need a complete accessible phrase.
437
483
 
484
+ Use `animation="roll"` for vertical, place-aligned digit reels. Integers align
485
+ from the right and fractions from the decimal point; carry, multiplication,
486
+ shrinking values and sign changes expand or collapse the affected columns.
487
+ Grouping, signs, currency and compact units remain formatted text, not digit
488
+ reels. `rollDirection="auto"` rolls up for increasing values and down for
489
+ decreasing values; `up` or `down` fixes the direction. `duration` and `easing`
490
+ apply to both modes. The default `animation="count"` retains value interpolation.
491
+ `renderValue` takes precedence over the built-in reels. Changing the formatting
492
+ or animation mode settles immediately, as do disabled/reduced motion, invalid
493
+ durations, and transitions with more than 128 formatted tokens. Exact strings
494
+ and bigint values retain their original precision in either mode.
495
+
496
+ ```tsx
497
+ <NumberTicker value={amount} animation="roll" rollDirection="auto" duration={650} />
498
+ ```
499
+
438
500
  Set `compact` to follow `locale` (Chinese units for `zh`, English units otherwise),
439
501
  or use `compact="en"` / `compact="zh"` to select a unit table explicitly.
440
502
  English uses this library's short-scale suffix convention: K, M, B, T, Qa, Qi,
@@ -546,6 +608,33 @@ The matching inherited tokens are `--inkra-icon-stroke-width`,
546
608
  and `--inkra-icon-break-count`. Finite explicit props override those tokens.
547
609
  See `docs/pencil-contract.md` in the workspace for the material contract.
548
610
 
611
+ Icons support `variant="outline"` (default) and `variant="filled"`. Filled icons
612
+ keep holes and internal marks transparent, join overlapping silhouettes and do
613
+ not close open arrows or checks into polygons. Set `fillColor` independently of
614
+ the contour's CSS `color`; it defaults to `currentColor` and accepts CSS variables.
615
+ The silhouette stays continuous while stroke controls affect its exterior.
616
+
617
+ ```tsx
618
+ <PencilIcon paths={folderPaths} variant="filled" fillColor="var(--folder-color)"
619
+ sourceSize={24} size={32} style={{ color: 'var(--inkra-ink)' }} />
620
+ ```
621
+
622
+ Supply `filledPaths` when a shape needs a dedicated filled silhouette. Each path
623
+ can use `mode: 'fill'` with an optional `color` for independently colored regions,
624
+ or `mode: 'cutout'` for transparent details. Closed cutouts remove an area; open
625
+ cutouts remove a stroke without implicitly closing it. Explicit fills accept
626
+ `fillRule: 'nonzero' | 'evenodd'` (default `nonzero`). These authored regions do
627
+ not add their own contour stroke. For lighter colored-pencil regions, use
628
+ `mode: 'wash'` with the same optional `color` and `fillRule`: paper remains visible
629
+ between pigment strokes, with a light pencil edge in the icon's CSS `color`.
630
+ Dense `fill` regions are unchanged.
631
+ Explicit `mode: 'stroke'` keeps a line symbol
632
+ even when its path closes; omitted mode detects filled silhouettes automatically.
633
+ An empty `filledPaths` intentionally hides the
634
+ base shape in filled mode. `badge` accepts the same three props and inherits the
635
+ base variant and fill color unless overridden. Intensity applies once to the
636
+ completed image, including its fills, exterior strokes and badge.
637
+
549
638
  `PencilLine` is an aria-hidden canvas decoration using the same pressure, grain, breaks, and repeated-pass renderer as surfaces and icons. It defaults to horizontal and supports a native vertical stroke without rotating a horizontal bitmap:
550
639
 
551
640
  ```tsx
@@ -555,10 +644,28 @@ See `docs/pencil-contract.md` in the workspace for the material contract.
555
644
 
556
645
  ### Rendering tokens
557
646
 
647
+ `ThemeProvider` accepts typography through `tokens`. Set
648
+ `--inkra-font-hand`, `--inkra-font-body` and `--inkra-font-display` to CSS font
649
+ stacks (load your chosen font separately). Set `--inkra-letter-effect: 0` to
650
+ display plain selectable text in that font, without the pencil canvas.
651
+ The default is 1 (enabled). This inherited toggle preserves all lettering
652
+ parameters and does not affect icons, surface fills or borders.
653
+
654
+ ```tsx
655
+ <ThemeProvider tokens={{
656
+ '--inkra-font-hand': '"Inkra Xiaolai", cursive',
657
+ '--inkra-font-body': '"Inkra Xiaolai", cursive',
658
+ '--inkra-font-display': '"Inkra Xiaolai", cursive',
659
+ '--inkra-letter-effect': 0,
660
+ }}>
661
+ <Button>普通字体效果</Button>
662
+ </ThemeProvider>
663
+ ```
664
+
558
665
  `PencilText` reads `--inkra-letter-variation`, `--inkra-letter-breaks`,
559
666
  `--inkra-letter-outline`, `--inkra-letter-fill`, and `--inkra-letter-grain` from
560
667
  its computed style. Values are clamped to 0-1; missing or invalid values use the
561
- default lettering preset (0.20, 0.50, 0.90, 0.90, 0.60 respectively). Apply these tokens to an ancestor to customize all
668
+ default Xiaolai lettering preset (0.10, 0.31, 0.44, 0.85, 0.40 respectively, revised 2026-10-28). Apply these tokens to an ancestor to customize all
562
669
  text within that scope without changing layout, selectable text, or labels.
563
670
 
564
671
  `PencilLine` reads `--inkra-stroke-width` (0.3-3) and
@@ -583,7 +690,13 @@ unavailable-canvas fallbacks remain intact.
583
690
 
584
691
  ## Font license
585
692
 
586
- The package includes `Trip Map Hand`, a modified web subset of Yozai Light v0.868, under the SIL Open Font License 1.1. The font is not covered by Inkra's MIT software license. See `THIRD_PARTY_NOTICES.md` and `Yozai-OFL.txt` in the package.
693
+ The default font is Xiaolai SC Regular v3.120, registered as `Inkra Xiaolai`.
694
+ Common and extended characters are separate WOFF2 resources loaded through
695
+ `unicode-range`. Override `--inkra-font-hand` to choose another font; the source
696
+ text and canvas use the same computed family. The package also retains
697
+ `Trip Map Hand`, a web subset of Yozai Light v0.868, for compatibility.
698
+ Both fonts use SIL OFL 1.1, separately from Inkra's MIT software license.
699
+ See `THIRD_PARTY_NOTICES.md`, `Xiaolai-OFL.txt` and `Yozai-OFL.txt`.
587
700
 
588
701
  ## Development
589
702
 
@@ -594,3 +707,25 @@ npm run pack:check
594
707
  ```
595
708
 
596
709
  `npm test` builds the package before running the SSR and determinism tests. `pack:check` prints the files that would be included without publishing anything.
710
+
711
+ ## Slider endpoints and circular dragging
712
+
713
+ Slider and RangeSlider use `thumbAlignment="edge"` by default: the thumb center
714
+ reaches either end of the track. Set `thumbAlignment="contain"` to keep the
715
+ previous inset positioning. This setting is independent of the visual variant.
716
+
717
+ `CircularSlider` is an optional single-value control exported from
718
+ `@inkra/react` and `@inkra/react/slider`. It supports controlled and uncontrolled
719
+ values, min/max/step, pointer and touch dragging, keyboard controls, disabled
720
+ and read-only states, form submission/reset, and a configurable arc. Angles are
721
+ measured clockwise from the top; `startAngle={0} sweepAngle={360}` draws a full
722
+ ring. `clockwise` controls the value direction. Arrow Right/Up increase,
723
+ Arrow Left/Down decrease, Home/End reach bounds and Page keys take larger steps.
724
+ Give the control an accessible name and use `getValueLabel` for units.
725
+
726
+ Use `centerIcon` for a decorative icon. The default center displays the value;
727
+ set `showValue={false}` for an icon alone. `centerContent` accepts a React node
728
+ or `(value: number) => ReactNode`, overriding the default value display when
729
+ provided; `null` hides the text. Strings and numbers retain the pencil text
730
+ appearance. Center content is inert and hidden from accessibility APIs;
731
+ keep the slider's `aria-label` and `getValueLabel` meaningful independently.
@@ -1,5 +1,14 @@
1
1
  # Third-Party Notices
2
2
 
3
+ ## Xiaolai SC
4
+
5
+ `dist/assets/ui-hand-xiaolai*.woff2` contains Xiaolai SC Regular v3.120 by LXGW,
6
+ converted to WOFF2 and split into common and extended character sets without
7
+ dropping mapped characters. It is registered as `Inkra Xiaolai` in CSS.
8
+ Source: https://github.com/lxgw/kose-font/releases/tag/v3.120
9
+ License: SIL Open Font License 1.1, reproduced in `Xiaolai-OFL.txt`.
10
+ This font is not covered by Inkra's MIT software license.
11
+
3
12
  ## Trip Map Hand font subset
4
13
 
5
14
  `dist/assets/ui-hand-yozai.woff2` is a web subset of Yozai Light v0.868 by LXGW, derived from work by Y.Oz. The subset uses the modified family name `Trip Map Hand` and is included to provide the handwriting geometry used by Inkra's pencil-text renderer.
@@ -0,0 +1,94 @@
1
+ Copyright 2020-2024 LXGW (https://github.com/lxgw/kose-font)
2
+ Copyright 2014 Nozomi Seto (https://ja.osdn.net/projects/setofont/)
3
+
4
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
5
+ This license is copied below, and is also available with a FAQ at:
6
+ http://scripts.sil.org/OFL
7
+
8
+
9
+ -----------------------------------------------------------
10
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
11
+ -----------------------------------------------------------
12
+
13
+ PREAMBLE
14
+ The goals of the Open Font License (OFL) are to stimulate worldwide
15
+ development of collaborative font projects, to support the font creation
16
+ efforts of academic and linguistic communities, and to provide a free and
17
+ open framework in which fonts may be shared and improved in partnership
18
+ with others.
19
+
20
+ The OFL allows the licensed fonts to be used, studied, modified and
21
+ redistributed freely as long as they are not sold by themselves. The
22
+ fonts, including any derivative works, can be bundled, embedded,
23
+ redistributed and/or sold with any software provided that any reserved
24
+ names are not used by derivative works. The fonts and derivatives,
25
+ however, cannot be released under any other type of license. The
26
+ requirement for fonts to remain under this license does not apply
27
+ to any document created using the fonts or their derivatives.
28
+
29
+ DEFINITIONS
30
+ "Font Software" refers to the set of files released by the Copyright
31
+ Holder(s) under this license and clearly marked as such. This may
32
+ include source files, build scripts and documentation.
33
+
34
+ "Reserved Font Name" refers to any names specified as such after the
35
+ copyright statement(s).
36
+
37
+ "Original Version" refers to the collection of Font Software components as
38
+ distributed by the Copyright Holder(s).
39
+
40
+ "Modified Version" refers to any derivative made by adding to, deleting,
41
+ or substituting -- in part or in whole -- any of the components of the
42
+ Original Version, by changing formats or by porting the Font Software to a
43
+ new environment.
44
+
45
+ "Author" refers to any designer, engineer, programmer, technical
46
+ writer or other person who contributed to the Font Software.
47
+
48
+ PERMISSION & CONDITIONS
49
+ Permission is hereby granted, free of charge, to any person obtaining
50
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
51
+ redistribute, and sell modified and unmodified copies of the Font
52
+ Software, subject to the following conditions:
53
+
54
+ 1) Neither the Font Software nor any of its individual components,
55
+ in Original or Modified Versions, may be sold by itself.
56
+
57
+ 2) Original or Modified Versions of the Font Software may be bundled,
58
+ redistributed and/or sold with any software, provided that each copy
59
+ contains the above copyright notice and this license. These can be
60
+ included either as stand-alone text files, human-readable headers or
61
+ in the appropriate machine-readable metadata fields within text or
62
+ binary files as long as those fields can be easily viewed by the user.
63
+
64
+ 3) No Modified Version of the Font Software may use the Reserved Font
65
+ Name(s) unless explicit written permission is granted by the corresponding
66
+ Copyright Holder. This restriction only applies to the primary font name as
67
+ presented to the users.
68
+
69
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
70
+ Software shall not be used to promote, endorse or advertise any
71
+ Modified Version, except to acknowledge the contribution(s) of the
72
+ Copyright Holder(s) and the Author(s) or with their explicit written
73
+ permission.
74
+
75
+ 5) The Font Software, modified or unmodified, in part or in whole,
76
+ must be distributed entirely under this license, and must not be
77
+ distributed under any other license. The requirement for fonts to
78
+ remain under this license does not apply to any document created
79
+ using the Font Software.
80
+
81
+ TERMINATION
82
+ This license becomes null and void if any of the above conditions are
83
+ not met.
84
+
85
+ DISCLAIMER
86
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
87
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
88
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
89
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
90
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
91
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
92
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
93
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
94
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -1,10 +1,17 @@
1
1
  import type { CanvasHTMLAttributes } from 'react';
2
2
  export interface PencilIconPath {
3
3
  d: string;
4
- mode?: 'stroke' | 'wash' | 'scuff';
4
+ /** Filled icons: stroke stays a line, wash exposes paper, fill is dense; omitted mode detects closed silhouettes. */
5
+ mode?: 'stroke' | 'wash' | 'scuff' | 'fill' | 'cutout';
6
+ /** Optional pigment for an explicit fill region, including CSS custom properties. */
7
+ color?: string;
8
+ fillRule?: CanvasFillRule;
5
9
  }
6
10
  export interface PencilIconBadge {
7
11
  paths: readonly PencilIconPath[];
12
+ filledPaths?: readonly PencilIconPath[];
13
+ variant?: 'outline' | 'filled';
14
+ fillColor?: string;
8
15
  sourceSize?: number;
9
16
  position?: 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end';
10
17
  /** Relative canvas size, clamped to 0.35-0.65. Defaults to 0.55. */
@@ -12,6 +19,12 @@ export interface PencilIconBadge {
12
19
  }
13
20
  export interface PencilIconProps extends Omit<CanvasHTMLAttributes<HTMLCanvasElement>, 'children' | 'height' | 'width'> {
14
21
  paths: readonly PencilIconPath[];
22
+ /** Filled icons retain open symbols and use negative space for interior details. */
23
+ variant?: 'outline' | 'filled';
24
+ /** Optional authored geometry for shapes whose filled silhouette differs from the outline. */
25
+ filledPaths?: readonly PencilIconPath[];
26
+ /** Body pigment, independent of the CSS color used for the contour. Defaults to currentColor. */
27
+ fillColor?: string;
15
28
  badge?: PencilIconBadge;
16
29
  seed?: string | number;
17
30
  size?: number;
@@ -6,6 +6,7 @@ import { seedToNumber } from './internal/random.js';
6
6
  import { pencilStroke } from './internal/stroke.js';
7
7
  import { maxStrokeRoughness, pencilRenderDefaults, readRenderToken } from './internal/renderTokens.js';
8
8
  import { cachedPathParts } from './internal/iconGeometry.js';
9
+ import { paintFilledIcon } from './internal/iconFill.js';
9
10
  const imageCache = new BoundedCache(128, 24 * 1024 * 1024);
10
11
  function drawFallbackPath(context, data, color, mode, weightScale = 1) {
11
12
  try {
@@ -47,56 +48,62 @@ function renderIcon(paths, options) {
47
48
  const offset = (40 - sourceSize) / 2;
48
49
  context.translate(offset, offset);
49
50
  let sampledPoints = 0;
50
- paths.forEach((path, index) => {
51
- if (path.d.length > 65536)
52
- return;
53
- const mode = path.mode ?? 'stroke';
54
- const pathSeed = seed + index * 137;
55
- if (mode === 'wash') {
51
+ if (options.variant === 'filled') {
52
+ paintFilledIcon(context, paths, { color, fillColor: options.fillColor ?? color, sourceSize, seed, weightScale, roughnessScale, breaks, breakCount, pressureVariation, coordinateScale });
53
+ }
54
+ else
55
+ paths.forEach((path, index) => {
56
+ if (path.d.length > 65536)
57
+ return;
58
+ const mode = path.mode ?? 'stroke';
59
+ if (mode === 'cutout')
60
+ return;
61
+ const pathSeed = seed + index * 137;
62
+ if (mode === 'wash' || mode === 'fill') {
63
+ try {
64
+ context.save();
65
+ context.clip(new Path2D(path.d));
66
+ context.globalAlpha = active ? 0.76 : 0.34;
67
+ for (let y = sourceSize * 0.18; y < sourceSize; y += 1.6) {
68
+ pencilStroke(context, [[sourceSize * 0.15, y + 3], [sourceSize * 0.85, y - 3]], color, 1.1 * weightScale / (pencilRenderDefaults.strokeWidth / 1.2), pathSeed + Math.round(y * 17), 0.25, 2, false, { variation: Math.min(1, 0.85 * variationScale), fitPressure: true, breaks, breakCount, breakScale: coordinateScale, continuousBreaks: true, grain: 0.7, gain: 2.2, step: 0.35 });
69
+ }
70
+ context.restore();
71
+ }
72
+ catch {
73
+ drawFallbackPath(context, path.d, color, mode, weightScale);
74
+ }
75
+ return;
76
+ }
56
77
  try {
57
- context.save();
58
- context.clip(new Path2D(path.d));
59
- context.globalAlpha = active ? 0.76 : 0.34;
60
- for (let y = sourceSize * 0.18; y < sourceSize; y += 1.6) {
61
- pencilStroke(context, [[sourceSize * 0.15, y + 3], [sourceSize * 0.85, y - 3]], color, 1.1 * weightScale / (pencilRenderDefaults.strokeWidth / 1.2), pathSeed + Math.round(y * 17), 0.25, 2, false, { variation: Math.min(1, 0.85 * variationScale), fitPressure: true, breaks, breakCount, breakScale: coordinateScale, continuousBreaks: true, grain: 0.7, gain: 2.2, step: 0.35 });
78
+ if (sampledPoints >= 65536) {
79
+ drawFallbackPath(context, path.d, color, mode, weightScale);
80
+ return;
81
+ }
82
+ const parts = cachedPathParts(path.d);
83
+ sampledPoints += parts.reduce((total, part) => total + part.length, 0);
84
+ if (sampledPoints > 65536) {
85
+ drawFallbackPath(context, path.d, color, mode, weightScale);
86
+ return;
87
+ }
88
+ for (const points of parts) {
89
+ pencilStroke(context, points, color, (mode === 'scuff' ? 0.55 : 1.65) * weightScale, pathSeed, 0.3 * roughnessScale, mode === 'scuff' ? 1 : 3, false, {
90
+ variation: pressureVariation,
91
+ fitPressure: true,
92
+ breaks,
93
+ breakCount,
94
+ breakScale: coordinateScale,
95
+ continuousBreaks: true,
96
+ grain: 0.68,
97
+ gain: mode === 'scuff' ? 1.5 : 3,
98
+ step: 0.35,
99
+ taperLength: 0.65,
100
+ });
62
101
  }
63
- context.restore();
64
102
  }
65
103
  catch {
66
104
  drawFallbackPath(context, path.d, color, mode, weightScale);
67
105
  }
68
- return;
69
- }
70
- try {
71
- if (sampledPoints >= 65536) {
72
- drawFallbackPath(context, path.d, color, mode, weightScale);
73
- return;
74
- }
75
- const parts = cachedPathParts(path.d);
76
- sampledPoints += parts.reduce((total, part) => total + part.length, 0);
77
- if (sampledPoints > 65536) {
78
- drawFallbackPath(context, path.d, color, mode, weightScale);
79
- return;
80
- }
81
- for (const points of parts) {
82
- pencilStroke(context, points, color, (mode === 'scuff' ? 0.55 : 1.65) * weightScale, pathSeed, 0.3 * roughnessScale, mode === 'scuff' ? 1 : 3, false, {
83
- variation: pressureVariation,
84
- fitPressure: true,
85
- breaks,
86
- breakCount,
87
- breakScale: coordinateScale,
88
- continuousBreaks: true,
89
- grain: 0.68,
90
- gain: mode === 'scuff' ? 1.5 : 3,
91
- step: 0.35,
92
- taperLength: 0.65,
93
- });
94
- }
95
- }
96
- catch {
97
- drawFallbackPath(context, path.d, color, mode, weightScale);
98
- }
99
- });
106
+ });
100
107
  if (active) {
101
108
  pencilStroke(context, [[sourceSize * 0.2, sourceSize - 2], [sourceSize * 0.5, sourceSize - 1.5], [sourceSize * 0.8, sourceSize - 2.2]], color, 1.1 * weightScale, seed + 977, 0.25 * roughnessScale, 3, false, { variation: 0.8 * variationScale, fitPressure: true, breaks, breakCount, breakScale: coordinateScale, continuousBreaks: true, grain: 0.7, gain: 2.8, step: 0.35 });
102
109
  }
@@ -109,8 +116,10 @@ function renderIcon(paths, options) {
109
116
  const extent = 40 * scale;
110
117
  const x = onLeft ? 0 : 40 - extent;
111
118
  const y = position.startsWith('top') ? 0 : 40 - extent;
112
- const badgeImage = renderIcon(badge.paths, {
119
+ const badgeVariant = badge.variant ?? options.variant;
120
+ const badgeImage = renderIcon(badgeVariant === 'filled' ? badge.filledPaths ?? badge.paths : badge.paths, {
113
121
  seed: seed + 1543, size: size * scale, sourceSize: badgeSourceSize, active: false, color, dpr,
122
+ variant: badgeVariant, fillColor: badge.fillColor ?? options.fillColor,
114
123
  weightScale: weightScale / scale, roughnessScale, breaks, breakCount, pressureVariation,
115
124
  });
116
125
  const mask = document.createElement('canvas');
@@ -140,17 +149,18 @@ function renderIcon(paths, options) {
140
149
  }
141
150
  return canvas;
142
151
  }
143
- export const PencilIcon = forwardRef(function PencilIcon({ active = false, badge, className = '', decorative = true, paths, seed, size = 40, sourceSize = 32, strokeWidth, intensity, breaks, breakCount, pressureVariation, ...props }, forwardedRef) {
152
+ export const PencilIcon = forwardRef(function PencilIcon({ active = false, badge, className = '', decorative = true, paths, variant = 'outline', filledPaths, fillColor, seed, size = 40, sourceSize = 32, strokeWidth, intensity, breaks, breakCount, pressureVariation, ...props }, forwardedRef) {
144
153
  const canvasRef = useRef(null);
145
154
  const lastPaintRef = useRef('');
146
155
  useImperativeHandle(forwardedRef, () => canvasRef.current);
147
156
  // Compare values, not caller object identity. Serializing each render also
148
157
  // detects in-place edits; the effect receives a stable snapshot of that value.
149
- const geometryKey = JSON.stringify(paths);
158
+ const originalGeometryKey = JSON.stringify(paths);
159
+ const geometryKey = variant === 'filled' && filledPaths ? JSON.stringify(filledPaths) : originalGeometryKey;
150
160
  const badgeKey = badge ? JSON.stringify(badge) : '';
151
161
  const stablePaths = useMemo(() => JSON.parse(geometryKey), [geometryKey]);
152
162
  const stableBadge = useMemo(() => badgeKey ? JSON.parse(badgeKey) : undefined, [badgeKey]);
153
- const resolvedSeed = seedToNumber(seed ?? geometryKey);
163
+ const resolvedSeed = seedToNumber(seed ?? originalGeometryKey);
154
164
  useEffect(() => {
155
165
  const canvas = canvasRef.current;
156
166
  if (!canvas)
@@ -162,6 +172,28 @@ export const PencilIcon = forwardRef(function PencilIcon({ active = false, badge
162
172
  const dpr = effectiveDpr(3);
163
173
  const computed = getComputedStyle(canvas);
164
174
  const color = computed.color;
175
+ const resolveColor = (value) => {
176
+ if (!value || value === 'currentColor')
177
+ return color;
178
+ const probe = document.createElement('span');
179
+ probe.style.color = value;
180
+ // Canvas fallback children inherit its own color and custom properties, not just its parent's.
181
+ canvas.appendChild(probe);
182
+ try {
183
+ return getComputedStyle(probe).color || color;
184
+ }
185
+ finally {
186
+ probe.remove();
187
+ }
188
+ };
189
+ const resolvedFill = variant === 'filled' ? resolveColor(fillColor) : color;
190
+ const resolvePaths = (items) => items.map(path => path.color ? { ...path, color: resolveColor(path.color) } : path);
191
+ const renderedPaths = variant === 'filled' ? resolvePaths(stablePaths) : stablePaths;
192
+ const renderedBadge = stableBadge ? { ...stableBadge,
193
+ fillColor: resolveColor(stableBadge.fillColor ?? fillColor),
194
+ paths: resolvePaths(stableBadge.paths),
195
+ filledPaths: stableBadge.filledPaths ? resolvePaths(stableBadge.filledPaths) : undefined,
196
+ } : undefined;
165
197
  const rtl = computed.direction === 'rtl';
166
198
  const iconWeight = readRenderToken(computed, '--inkra-icon-stroke-width', pencilRenderDefaults.iconStrokeWidth, 0.3, 3);
167
199
  const weightScale = (typeof strokeWidth === 'number' && Number.isFinite(strokeWidth) ? Math.min(3, Math.max(0.3, strokeWidth)) : iconWeight) / 1.2;
@@ -180,9 +212,10 @@ export const PencilIcon = forwardRef(function PencilIcon({ active = false, badge
180
212
  : readRenderToken(computed, '--inkra-icon-break-count', pencilRenderDefaults.iconBreakCount, 0, 12));
181
213
  const key = JSON.stringify([
182
214
  PENCIL_RENDERER_VERSION,
183
- 'icon-path-pressure-gaps-v4',
215
+ 'icon-path-paper-wash-v8',
184
216
  geometryKey,
185
217
  badgeKey,
218
+ variant, resolvedFill, renderedPaths, renderedBadge,
186
219
  rtl,
187
220
  resolvedSeed,
188
221
  size,
@@ -202,7 +235,7 @@ export const PencilIcon = forwardRef(function PencilIcon({ active = false, badge
202
235
  image = undefined;
203
236
  }
204
237
  if (!image) {
205
- image = renderIcon(stablePaths, { seed: resolvedSeed, size, sourceSize, active, color, dpr, badge: stableBadge, rtl, weightScale, roughnessScale, breaks: strokeBreaks, breakCount: strokeBreakCount, pressureVariation: strokeVariation });
238
+ image = renderIcon(renderedPaths, { seed: resolvedSeed, size, sourceSize, active, color, dpr, variant, fillColor: resolvedFill, badge: renderedBadge, rtl, weightScale, roughnessScale, breaks: strokeBreaks, breakCount: strokeBreakCount, pressureVariation: strokeVariation });
206
239
  imageCache.set(key, image, canvasByteCost(image));
207
240
  }
208
241
  canvas.width = image.width;
@@ -236,7 +269,9 @@ export const PencilIcon = forwardRef(function PencilIcon({ active = false, badge
236
269
  inheritedColor.observe(element, { attributes: true, attributeFilter: ['style', 'class', 'dir', 'data-theme', 'data-inkra-theme'] });
237
270
  }
238
271
  const colorScheme = window.matchMedia('(prefers-color-scheme: dark)');
272
+ const forcedColors = window.matchMedia('(forced-colors: active)');
239
273
  colorScheme.addEventListener('change', paint);
274
+ forcedColors.addEventListener('change', paint);
240
275
  const stopRecovery = observeCanvasRecovery(() => { lastPaintRef.current = ''; paint(); });
241
276
  paint();
242
277
  return () => {
@@ -245,10 +280,11 @@ export const PencilIcon = forwardRef(function PencilIcon({ active = false, badge
245
280
  stopRecovery();
246
281
  inheritedColor.disconnect();
247
282
  colorScheme.removeEventListener('change', paint);
283
+ forcedColors.removeEventListener('change', paint);
248
284
  };
249
- }, [active, stableBadge, badgeKey, geometryKey, stablePaths, resolvedSeed, size, sourceSize, strokeWidth, intensity, breaks, breakCount, pressureVariation]);
285
+ }, [active, stableBadge, badgeKey, geometryKey, stablePaths, resolvedSeed, size, sourceSize, strokeWidth, intensity, breaks, breakCount, pressureVariation, variant, fillColor]);
250
286
  const accessibilityProps = decorative
251
287
  ? { 'aria-hidden': true }
252
288
  : { role: props.role ?? 'img' };
253
- return (_jsx("canvas", { ...props, ...accessibilityProps, className: ['inkra-pencil-icon', className].filter(Boolean).join(' '), "data-active": active || undefined, "data-composite": Boolean(badge?.paths.length) || undefined, "data-renderer": "inkra-pressure-pencil", ref: canvasRef, style: { width: size, height: size, ...props.style } }));
289
+ return (_jsx("canvas", { ...props, ...accessibilityProps, className: ['inkra-pencil-icon', className].filter(Boolean).join(' '), "data-active": active || undefined, "data-icon-variant": variant, "data-composite": Boolean(badge?.paths.length) || undefined, "data-renderer": "inkra-pressure-pencil", ref: canvasRef, style: { width: size, height: size, ...props.style } }));
254
290
  });
@@ -97,6 +97,9 @@ function measureLocalTextLines(element, liveSource, text, width, height) {
97
97
  }
98
98
  function paintText(element, source, canvas, text, seed, glyphSeed) {
99
99
  const style = getComputedStyle(element);
100
+ // Plain mode retains the selectable source and font metrics, without painting.
101
+ if (readRenderToken(style, '--inkra-letter-effect', 1, 0, 1) === 0)
102
+ return false;
100
103
  const { width: localWidth, height: localHeight } = localBorderBox(element, style);
101
104
  if (!localWidth || !localHeight || text.length > 4096 || !source.firstChild)
102
105
  return false;
@@ -255,7 +258,7 @@ const PencilTextImplementation = forwardRef(function PencilText({ as: Element =
255
258
  ...['color', 'font-family', 'font-size', 'font-weight', 'font-style', 'line-height', 'text-align', 'text-indent',
256
259
  'letter-spacing', 'white-space', 'word-break', 'overflow-wrap', 'direction',
257
260
  'padding-top', 'padding-left', 'padding-right', 'padding-bottom',
258
- '--inkra-letter-variation', '--inkra-letter-breaks', '--inkra-letter-outline',
261
+ '--inkra-letter-effect', '--inkra-letter-variation', '--inkra-letter-breaks', '--inkra-letter-outline',
259
262
  '--inkra-letter-fill', '--inkra-letter-grain'].map(name => style.getPropertyValue(name)),
260
263
  ]);
261
264
  };